Explorar o código

Improved feed action filters (#3303)

* Re-order some feed options
* Option to auto mark as read existing titles
* Option to keep at max n unread articles per feed
Alexandre Alapetite %!s(int64=4) %!d(string=hai) anos
pai
achega
a7aca6c0ab

+ 6 - 3
app/Controllers/configureController.php

@@ -121,9 +121,12 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 			FreshRSS_Context::$user_conf->sort_order = Minz_Request::param('sort_order', 'DESC');
 			FreshRSS_Context::$user_conf->mark_when = array(
 				'article' => Minz_Request::param('mark_open_article', false),
-				'site' => Minz_Request::param('mark_open_site', false),
-				'scroll' => Minz_Request::param('mark_scroll', false),
+				'max_n_unread' => Minz_Request::paramBoolean('enable_keep_max_n_unread') ? Minz_Request::param('keep_max_n_unread', false) : false,
 				'reception' => Minz_Request::param('mark_upon_reception', false),
+				'same_title_in_feed' => Minz_Request::paramBoolean('enable_read_when_same_title_in_feed') ?
+					Minz_Request::param('read_when_same_title_in_feed', false) : false,
+				'scroll' => Minz_Request::param('mark_scroll', false),
+				'site' => Minz_Request::param('mark_open_site', false),
 			);
 			FreshRSS_Context::$user_conf->save();
 			invalidateHttpCache();
@@ -210,7 +213,7 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 			} elseif (!$keepMax = Minz_Request::param('keep_max')) {
 				$keepMax = FreshRSS_Feed::ARCHIVING_RETENTION_COUNT_LIMIT;
 			}
-			if ($enableRetentionPeriod = Minz_Request::paramBoolean('enable_keep_period')) {
+			if (Minz_Request::paramBoolean('enable_keep_period')) {
 				$keepPeriod = FreshRSS_Feed::ARCHIVING_RETENTION_PERIOD;
 				if (is_numeric(Minz_Request::param('keep_period_count')) && preg_match('/^PT?1[YMWDH]$/', Minz_Request::param('keep_period_unit'))) {
 					$keepPeriod = str_replace('1', Minz_Request::param('keep_period_count'), Minz_Request::param('keep_period_unit'));

+ 22 - 4
app/Controllers/feedController.php

@@ -359,6 +359,19 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			$needFeedCacheRefresh = false;
 
 			if (count($newGuids) > 0) {
+				$titlesAsRead = [];
+				$readWhenSameTitleInFeed = $feed->attributes('read_when_same_title_in_feed');
+				if ($readWhenSameTitleInFeed == false) {
+					$readWhenSameTitleInFeed = FreshRSS_Context::$user_conf->mark_when['same_title_in_feed'];
+				}
+				if ($readWhenSameTitleInFeed > 0) {
+					$titlesAsRead = array_flip($feedDAO->listTitles($feed->id(), $feed->attributes('read_when_same_title_in_feed')));
+				}
+
+				$mark_updated_article_unread = $feed->attributes('mark_updated_article_unread') !== null ? (
+						$feed->attributes('mark_updated_article_unread')
+					) : FreshRSS_Context::$user_conf->mark_updated_article_unread;
+
 				// For this feed, check existing GUIDs already in database.
 				$existingHashForGuids = $entryDAO->listHashForFeedGuids($feed->id(), $newGuids);
 				$newGuids = array();
@@ -379,11 +392,11 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 						} else {	//This entry already exists but has been updated
 							//Minz_Log::debug('Entry with GUID `' . $entry->guid() . '` updated in feed ' . $feed->url(false) .
 								//', old hash ' . $existingHash . ', new hash ' . $entry->hash());
-							$mark_updated_article_unread = $feed->attributes('mark_updated_article_unread') !== null ?
-								$feed->attributes('mark_updated_article_unread') :
-								FreshRSS_Context::$user_conf->mark_updated_article_unread;
 							$needFeedCacheRefresh = $mark_updated_article_unread;
 							$entry->_isRead($mark_updated_article_unread ? false : null);	//Change is_read according to policy.
+							if ($mark_updated_article_unread) {
+								$feed->incPendingUnread();	//Maybe
+							}
 
 							$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
 							if ($entry === null) {
@@ -403,7 +416,10 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 						$id = uTimeString();
 						$entry->_id($id);
 
-						$entry->applyFilterActions();
+						$entry->applyFilterActions($titlesAsRead);
+						if ($readWhenSameTitleInFeed > 0) {
+							$titlesAsRead[$entry->title()] = true;
+						}
 
 						$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
 						if ($entry === null) {
@@ -424,6 +440,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 							$entryDAO->beginTransaction();
 						}
 						$entryDAO->addEntry($entry->toArray());
+						$feed->incPendingUnread();
 						$nb_new_articles++;
 					}
 				}
@@ -445,6 +462,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			if ($needFeedCacheRefresh) {
 				$feedDAO->updateCachedValues($feed->id());
 			}
+			$feed->keepMaxUnread();
 			if ($entryDAO->inTransaction()) {
 				$entryDAO->commit();
 			}

+ 14 - 0
app/Controllers/subscriptionController.php

@@ -112,6 +112,20 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 			$feed->_attributes('read_upon_reception', Minz_Request::paramTernary('read_upon_reception'));
 			$feed->_attributes('clear_cache', Minz_Request::paramTernary('clear_cache'));
 
+			$keep_max_n_unread = intval(Minz_Request::param('keep_max_n_unread', 0));
+			$feed->_attributes('keep_max_n_unread', $keep_max_n_unread > 0 ? $keep_max_n_unread : null);
+
+			$read_when_same_title_in_feed = Minz_Request::param('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 = intval($read_when_same_title_in_feed);
+				if ($read_when_same_title_in_feed <= 0) {
+					$read_when_same_title_in_feed = false;
+				}
+			}
+			$feed->_attributes('read_when_same_title_in_feed', $read_when_same_title_in_feed);
+
 			$cookie = Minz_Request::param('curl_params_cookie', '');
 			$useragent = Minz_Request::param('curl_params_useragent', '');
 			$proxy_address = Minz_Request::param('curl_params', '');

+ 0 - 6
app/Models/ConfigurationSetter.php

@@ -205,12 +205,6 @@ class FreshRSS_ConfigurationSetter {
 		$data['lazyload'] = $this->handleBool($value);
 	}
 
-	private function _mark_when(&$data, $values) {
-		foreach ($values as $key => $value) {
-			$data['mark_when'][$key] = $this->handleBool($value);
-		}
-	}
-
 	private function _onread_jump_next(&$data, $value) {
 		$data['onread_jump_next'] = $this->handleBool($value);
 	}

+ 5 - 1
app/Models/Entry.php

@@ -341,12 +341,16 @@ class FreshRSS_Entry extends Minz_Model {
 		return false;
 	}
 
-	public function applyFilterActions() {
+	public function applyFilterActions($titlesAsRead = []) {
 		if ($this->feed != null) {
 			if ($this->feed->attributes('read_upon_reception') ||
 				($this->feed->attributes('read_upon_reception') === null && FreshRSS_Context::$user_conf->mark_when['reception'])) {
 				$this->_isRead(true);
 			}
+			if (isset($titlesAsRead[$this->title()])) {
+				Minz_Log::debug('Mark title as read: ' . $this->title());
+				$this->_isRead(true);
+			}
 			foreach ($this->feed->filterActions() as $filterAction) {
 				if ($this->matches($filterAction->booleanSearch())) {
 					foreach ($filterAction->actions() as $action) {

+ 21 - 2
app/Models/Feed.php

@@ -15,6 +15,7 @@ class FreshRSS_Feed extends Minz_Model {
 	private $category = 1;
 	private $nbEntries = -1;
 	private $nbNotRead = -1;
+	private $nbPendingNotRead = 0;
 	private $name = '';
 	private $website = '';
 	private $description = '';
@@ -141,13 +142,13 @@ class FreshRSS_Feed extends Minz_Model {
 
 		return $this->nbEntries;
 	}
-	public function nbNotRead() {
+	public function nbNotRead($includePending = false) {
 		if ($this->nbNotRead < 0) {
 			$feedDAO = FreshRSS_Factory::createFeedDao();
 			$this->nbNotRead = $feedDAO->countNotRead($this->id());
 		}
 
-		return $this->nbNotRead;
+		return $this->nbNotRead + ($includePending ? $this->nbPendingNotRead : 0);
 	}
 	public function faviconPrepare() {
 		require_once(LIB_PATH . '/favicons.php');
@@ -475,6 +476,24 @@ class FreshRSS_Feed extends Minz_Model {
 		}
 	}
 
+	/**
+	 * To keep track of some new potentially unread articles since last commit+fetch from database
+	 */
+	public function incPendingUnread($n = 1) {
+		$this->nbPendingNotRead += $n;
+	}
+
+	public function keepMaxUnread() {
+		$keepMaxUnread = $this->attributes('keep_max_n_unread');
+		if ($keepMaxUnread == false) {
+			$keepMaxUnread = FreshRSS_Context::$user_conf->mark_when['max_n_unread'];
+		}
+		if ($keepMaxUnread > 0 && $this->nbNotRead(false) + $this->nbPendingNotRead > $keepMaxUnread) {
+			$feedDAO = FreshRSS_Factory::createFeedDao();
+			$feedDAO->keepMaxUnread($this->id(), max(0, $keepMaxUnread - $this->nbPendingNotRead));
+		}
+	}
+
 	public function cleanOldEntries() {	//Remember to call updateCachedValue($id_feed) or updateCachedValues() just after
 		$archiving = $this->attributes('archiving');
 		if ($archiving == null) {

+ 53 - 0
app/Models/FeedDAO.php

@@ -363,6 +363,19 @@ SQL;
 		}
 	}
 
+	public function listTitles($id, $limit = null) {
+		$sql = 'SELECT title FROM `_entry` WHERE id_feed=:id_feed ORDER BY id DESC'
+			. ($limit < 1 ? '' : ' LIMIT ' . intval($limit));
+
+		$stm = $this->pdo->prepare($sql);
+		$stm->bindParam(':id_feed', $id, PDO::PARAM_INT);
+
+		if ($stm && $stm->execute()) {
+			return $stm->fetchAll(PDO::FETCH_COLUMN, 0);
+		}
+		return false;
+	}
+
 	public function listByCategory($cat) {
 		$sql = 'SELECT * FROM `_feed` WHERE category=?';
 		$stm = $this->pdo->prepare($sql);
@@ -418,6 +431,46 @@ SQL;
 		}
 	}
 
+	public function keepMaxUnread($id, $n) {
+		//Double SELECT for MySQL workaround ERROR 1093 (HY000)
+		$sql = <<<'SQL'
+UPDATE `_entry` SET is_read=1
+WHERE id_feed=:id_feed1 AND is_read=0 AND id <= (SELECT e3.id FROM (
+	SELECT e2.id FROM `_entry` e2
+	WHERE e2.id_feed=:id_feed2 AND e2.is_read=0
+	ORDER BY e2.id DESC
+	LIMIT 1
+	OFFSET :limit) e3)
+SQL;
+
+		$stm = $this->pdo->prepare($sql);
+		$stm->bindParam(':id_feed1', $id, PDO::PARAM_INT);
+		$stm->bindParam(':id_feed2', $id, PDO::PARAM_INT);
+		$stm->bindParam(':limit', $n, PDO::PARAM_INT);
+
+		if (!$stm || !$stm->execute()) {
+			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
+			Minz_Log::error('SQL error keepMaxUnread: ' . json_encode($info));
+			return false;
+		}
+		$affected = $stm->rowCount();
+
+		if ($affected > 0) {
+			$sql = 'UPDATE `_feed` '
+				 . 'SET `cache_nbUnreads`=`cache_nbUnreads`-' . $affected
+				 . ' WHERE id=:id';
+			$stm = $this->pdo->prepare($sql);
+			$stm->bindParam(':id', $id_feed, PDO::PARAM_INT);
+			if (!($stm && $stm->execute())) {
+				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
+				Minz_Log::error('SQL error keepMaxUnread cache: ' . json_encode($info));
+				return false;
+			}
+		}
+
+		return $affected;
+	}
+
 	public function truncate($id) {
 		$sql = 'DELETE FROM `_entry` WHERE id_feed=:id';
 		$stm = $this->pdo->prepare($sql);

+ 2 - 0
app/i18n/cz/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'když je otevřen původní web s článkem',
 			'article_viewed' => 'během čtení článku',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'během skrolování',
 			'upon_reception' => 'po načtení článku',
 			'when' => 'Označit článek jako přečtený…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'Počet zobrazených článků',

+ 2 - 0
app/i18n/de/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'wenn der Artikel auf der Original-Webseite geöffnet wird',
 			'article_viewed' => 'wenn der Artikel angesehen wird',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'beim Scrollen bzw. Überspringen',
 			'upon_reception' => 'beim Empfang des Artikels',
 			'when' => 'Artikel als gelesen markieren…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'Artikel zum Anzeigen',

+ 2 - 0
app/i18n/en-us/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'when the article is opened on its original website',
 			'article_viewed' => 'when the article is viewed',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',
 			'scroll' => 'while scrolling',
 			'upon_reception' => 'upon receiving the article',
 			'when' => 'Mark an article as read…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',
 		),
 		'show' => array(
 			'_' => 'Articles to display',

+ 2 - 0
app/i18n/en/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'when the article is opened on its original website',
 			'article_viewed' => 'when the article is viewed',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',
 			'scroll' => 'while scrolling',
 			'upon_reception' => 'upon receiving the article',
 			'when' => 'Mark an article as read…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',
 		),
 		'show' => array(
 			'_' => 'Articles to display',

+ 2 - 0
app/i18n/es/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'cuando el artículo se abra en su web original',
 			'article_viewed' => 'cuando se muestre el artículo',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'durante el desplazamiento',
 			'upon_reception' => 'al recibir el artículo',
 			'when' => 'Marcar el artículo como leído…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'Artículos a mostrar',

+ 2 - 0
app/i18n/fr/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'lorsque l’article est ouvert sur le site d’origine',
 			'article_viewed' => 'lorsque l’article est affiché',
+			'keep_max_n_unread' => 'Nombre maximum d’articles conservés non lus',
 			'scroll' => 'au défilement de la page',
 			'upon_reception' => 'dès la réception du nouvel article',
 			'when' => 'Marquer un article comme lu…',
+			'when_same_title' => 'si un même titre existe déjà dans les <i>n</i> articles plus récents',
 		),
 		'show' => array(
 			'_' => 'Articles à afficher',

+ 2 - 0
app/i18n/he/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'כאשר מאמר נפתח באתר המקורי',
 			'article_viewed' => 'כאשר מאמר נצפה',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'כאשר גוללים',
 			'upon_reception' => 'כאשר המאמר מתקבל',
 			'when' => 'סימון מאמרים כנקראו…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'מאמרים להצגה',

+ 2 - 0
app/i18n/it/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'Quando un articolo è aperto nel suo sito di origine',
 			'article_viewed' => 'Quando un articolo viene letto',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'Scorrendo la pagina',
 			'upon_reception' => 'Alla ricezione del contenuto',
 			'when' => 'Segna articoli come letti…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'Articoli da visualizzare',

+ 2 - 0
app/i18n/ja/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => '記事を元のwebサイトで開いたとき',
 			'article_viewed' => '記事を読んだとき',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'スクロールしているとき',
 			'upon_reception' => '記事を受け取ったとき',
 			'when' => '記事を既読にする…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => '記事を表示する',

+ 2 - 0
app/i18n/kr/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => '글이 게재된 웹사이트를 방문했을 때',
 			'article_viewed' => '글을 읽었을 때',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => '스크롤을 하며 지나갈 때',
 			'upon_reception' => '글을 가져오자마자',
 			'when' => '읽음으로 표시…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => '글 표시 방식',

+ 2 - 0
app/i18n/nl/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'als het artikel wordt geopend op de originele website',
 			'article_viewed' => 'als het artikel wordt bekeken',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'tijdens het scrollen',
 			'upon_reception' => 'bij ontvangst van het artikel',
 			'when' => 'Markeer artikel als gelezen…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'Artikelen om te tonen',

+ 2 - 0
app/i18n/oc/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'quand l’article es dobèrt sul site d’origina',
 			'article_viewed' => 'quand l’article es mostrat',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'en davalar la pagina',
 			'upon_reception' => 'en recebre un article novèl',
 			'when' => 'Marcar un article coma legit…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'Articles de mostrar',

+ 2 - 0
app/i18n/pl/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'gdy wiadomość jest otworzona na pierwotnej stronie',
 			'article_viewed' => 'gdy wiadomość jest otworzona',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'podczas przewijania',
 			'upon_reception' => 'po otrzymaniu wiadomości',
 			'when' => 'Oznacz wiadomość jako przeczytaną…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'Wiadomości do wyświetlenia',

+ 2 - 0
app/i18n/pt-br/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'quando o artigo é aberto no site original',
 			'article_viewed' => 'Quando o artigo é visualizado',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'enquanto scrolling',
 			'upon_reception' => 'ao receber um artigo',
 			'when' => 'Marcar artigo como lido…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'Artigos para exibir',

+ 2 - 0
app/i18n/ru/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'когда статья открывается на её сайте',
 			'article_viewed' => 'когда статья просматривается',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'во время прокрутки',
 			'upon_reception' => 'по получении статьи',
 			'when' => 'Отмечать статью прочитанной…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'Какие статьи отображать',

+ 2 - 0
app/i18n/sk/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'keď je článok otvorený na svojej webovej stránke',
 			'article_viewed' => 'keď je článok zobrazený',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'počas skrolovania',
 			'upon_reception' => 'po načítaní článku',
 			'when' => 'Označiť článok ako prečítaný…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'Článkov na zobrazenie',

+ 2 - 0
app/i18n/tr/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'orijinal makale sitesi açıldığında',
 			'article_viewed' => 'makale görüntülendiğinde',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => 'kaydırma yapılırken',
 			'upon_reception' => 'makale üzerinde gelince',
 			'when' => 'Makaleyi okundu olarak işaretle…',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => 'Gösterilecek makaleler',

+ 2 - 0
app/i18n/zh-cn/conf.php

@@ -127,9 +127,11 @@ return array(
 		'read' => array(
 			'article_open_on_website' => '在打开原文章后',
 			'article_viewed' => '在文章被浏览后',
+			'keep_max_n_unread' => 'Max number of articles to keep unread',	// TODO - Translation
 			'scroll' => '在滚动浏览后',
 			'upon_reception' => '在接收文章后',
 			'when' => '何时将文章标记为已读',
+			'when_same_title' => 'if an identical title already exists in the top <i>n</i> newest articles',	// TODO - Translation
 		),
 		'show' => array(
 			'_' => '文章显示',

+ 20 - 0
app/views/configure/reading.phtml

@@ -207,12 +207,32 @@
 						data-leave-validation="<?= FreshRSS_Context::$user_conf->mark_when['scroll'] ?>"/>
 					<?= _t('conf.reading.read.scroll') ?>
 				</label>
+
+				<label class="checkbox" for="keep_max_n_unread">
+					<input type="checkbox" name="enable_keep_max_n_unread" id="enable_keep_max_n_unread" value="1"<?=
+						empty(FreshRSS_Context::$user_conf->mark_when['max_n_unread']) ? '' : ' checked="checked"' ?>
+						data-leave-validation="<?= empty(FreshRSS_Context::$user_conf->mark_when['max_n_unread']) ? 0 : 1 ?>"/>
+					<?= _t('conf.reading.read.keep_max_n_unread') ?>
+					<?php $keep_max_n_unread = empty(FreshRSS_Context::$user_conf->mark_when['max_n_unread']) ? 1000 : FreshRSS_Context::$user_conf->mark_when['max_n_unread']; ?>
+					<input type="number" id="keep_max_n_unread" name="keep_max_n_unread" min="0" value="<?= $keep_max_n_unread ?>" data-leave-validation="<?= $keep_max_n_unread ?>" />
+				</label>
+
 				<label class="checkbox" for="check_reception">
 					<input type="checkbox" name="mark_upon_reception" id="check_reception" value="1"<?=
 						FreshRSS_Context::$user_conf->mark_when['reception'] ? ' checked="checked"' : '' ?>
 						data-leave-validation="<?= FreshRSS_Context::$user_conf->mark_when['reception'] ?>"/>
 					<?= _t('conf.reading.read.upon_reception') ?>
 				</label>
+
+				<label class="checkbox" for="read_when_same_title_in_feed">
+					<input type="checkbox" name="enable_read_when_same_title_in_feed" id="enable_read_when_same_title_in_feed" value="1"<?=
+						empty(FreshRSS_Context::$user_conf->mark_when['same_title_in_feed']) ? '' : ' checked="checked"' ?>
+						data-leave-validation="<?= empty(FreshRSS_Context::$user_conf->mark_when['same_title_in_feed']) ? 0 : 1 ?>"/>
+					<?= _t('conf.reading.read.when_same_title') ?>
+					<?php $read_when_same_title_in_feed = empty(FreshRSS_Context::$user_conf->mark_when['same_title_in_feed']) ? 25 : FreshRSS_Context::$user_conf->mark_when['same_title_in_feed']; ?>
+					<input type="number" id="read_when_same_title_in_feed" name="read_when_same_title_in_feed" min="0"
+						value="<?= $read_when_same_title_in_feed ?>" data-leave-validation="<?= $read_when_same_title_in_feed ?>" />
+				</label>
 			</div>
 		</div>
 

+ 139 - 108
app/views/helpers/feed/update.phtml

@@ -79,6 +79,43 @@
 			</div>
 		</div>
 
+		<div class="form-group">
+			<label class="group-name" for="ttl"><?= _t('sub.feed.ttl') ?></label>
+			<div class="group-controls">
+				<select class="number" name="ttl" id="ttl" required="required"><?php
+					$found = false;
+					foreach (array(FreshRSS_Feed::TTL_DEFAULT => _t('gen.short.by_default'), 900 => '15min', 1200 => '20min', 1500 => '25min', 1800 => '30min', 2700 => '45min',
+							3600 => '1h', 5400 => '1.5h', 7200 => '2h', 10800 => '3h', 14400 => '4h', 18800 => '5h', 21600 => '6h', 25200 => '7h', 28800 => '8h',
+							36000 => '10h', 43200 => '12h', 64800 => '18h',
+							86400 => '1d', 129600 => '1.5d', 172800 => '2d', 259200 => '3d', 345600 => '4d', 432000 => '5d', 518400 => '6d',
+							604800 => '1wk', 1209600 => '2wk', 1814400 => '3wk', 2419200 => '4wk', 2629744 => '1mo') as $v => $t) {
+						echo '<option value="' . $v . ($this->feed->ttl() === $v ? '" selected="selected' : '') . '">' . $t . '</option>';
+						if ($this->feed->ttl() == $v) {
+							$found = true;
+						}
+					}
+					if (!$found) {
+						echo '<option value="' . intval($this->feed->ttl()) . '" selected="selected">' . intval($this->feed->ttl()) . 's</option>';
+					}
+				?></select>
+				<label for="mute">
+					<input type="checkbox" name="mute" id="mute" value="1"<?= $this->feed->mute() ? ' checked="checked"' : '' ?> />
+					<?= _t('sub.feed.mute') ?>
+				</label>
+			</div>
+		</div>
+
+		<?php if ($this->feed->pubSubHubbubEnabled()) { ?>
+			<div class="form-group">
+				<div class="group-controls">
+					<label class="checkbox" for="pubsubhubbub">
+						<input type="checkbox" name="pubsubhubbub" id="pubsubhubbub" disabled="disabled" value="1" checked="checked" />
+						<?= _t('sub.feed.websub') ?>
+					</label>
+				</div>
+			</div>
+		<?php } ?>
+
 		<div class="form-group form-actions">
 			<div class="group-controls">
 				<button class="btn btn-important"><?= _t('gen.action.submit') ?></button>
@@ -90,6 +127,101 @@
 			</div>
 		</div>
 
+		<legend><?= _t('sub.feed.auth.configuration') ?></legend>
+		<?php $auth = $this->feed->httpAuth(false); ?>
+		<div class="form-group">
+			<label class="group-name" for="http_user_feed<?= $this->feed->id() ?>"><?= _t('sub.feed.auth.username') ?></label>
+			<div class="group-controls">
+				<input type="text" name="http_user_feed<?= $this->feed->id() ?>" id="http_user_feed<?= $this->feed->id() ?>" class="extend" value="<?=
+					empty($auth['username']) ? ' ' : $auth['username'] ?>" autocomplete="off" />
+				<p class="help"><?= _i('help') ?> <?= _t('sub.feed.auth.help') ?></p>
+			</div>
+
+			<label class="group-name" for="http_pass_feed<?= $this->feed->id() ?>"><?= _t('sub.feed.auth.password') ?></label>
+			<div class="group-controls">
+				<input type="password" name="http_pass_feed<?= $this->feed->id() ?>" id="http_pass_feed<?= $this->feed->id() ?>" class="extend" value="<?=
+					$auth['password'] ?>" autocomplete="new-password" />
+			</div>
+		</div>
+
+		<div class="form-group form-actions">
+			<div class="group-controls">
+				<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
+				<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
+			</div>
+		</div>
+
+		<legend><?= _t('sub.feed.filteractions') ?></legend>
+		<div class="form-group">
+			<label class="group-name" for="mark_updated_article_unread"><?= _t('conf.reading.mark_updated_article_unread') ?></label>
+			<div class="group-controls">
+				<label class="checkbox" for="mark_updated_article_unread">
+					<select name="mark_updated_article_unread" id="mark_updated_article_unread">
+						<option value=""<?= $this->feed->attributes('mark_updated_article_unread') === null ? ' selected="selected"' : '' ?>><?= _t('gen.short.by_default') ?></option>
+						<option value="0"<?= $this->feed->attributes('mark_updated_article_unread') === false ? ' selected="selected"' : '' ?>><?= _t('gen.short.no') ?></option>
+						<option value="1"<?= $this->feed->attributes('mark_updated_article_unread') === true ? ' selected="selected"' : '' ?>><?= _t('gen.short.yes') ?></option>
+					</select>
+				</label>
+			</div>
+		</div>
+
+		<div class="form-group">
+			<label class="group-name" for="keep_max_n_unread"><?= _t('conf.reading.read.keep_max_n_unread') ?></label>
+			<div class="group-controls">
+				<input type="number" name="keep_max_n_unread" id="keep_max_n_unread" min="1" max="10000000" value="<?= $this->feed->attributes('keep_max_n_unread') ?>" placeholder="<?= _t('gen.short.by_default') ?>" />
+			</div>
+		</div>
+
+		<div class="form-group">
+			<label class="group-name" for="read_upon_reception"><?= _t('conf.reading.read.when') ?></label>
+			<div class="group-controls">
+				<label class="checkbox" for="read_upon_reception">
+					<select name="read_upon_reception" id="read_upon_reception">
+						<option value=""<?= $this->feed->attributes('read_upon_reception') === null ? ' selected="selected"' : '' ?>><?= _t('gen.short.by_default') ?></option>
+						<option value="0"<?= $this->feed->attributes('read_upon_reception') === false ? ' selected="selected"' : '' ?>><?= _t('gen.short.no') ?></option>
+						<option value="1"<?= $this->feed->attributes('read_upon_reception') === true ? ' selected="selected"' : '' ?>><?= _t('gen.short.yes') ?></option>
+					</select>
+					<?= _t('conf.reading.read.upon_reception') ?>
+				</label>
+			</div>
+		</div>
+
+		<div class="form-group">
+			<label class="group-name" for="read_when_same_title_in_feed"><?= _t('conf.reading.read.when') ?></label>
+			<div class="group-controls">
+				<label class="checkbox" for="read_when_same_title_in_feed">
+					<select name="read_when_same_title_in_feed" id="read_when_same_title_in_feed">
+						<option value=""<?= $this->feed->attributes('read_when_same_title_in_feed') === null ? ' selected="selected"' : '' ?>><?= _t('gen.short.by_default') ?></option>
+						<option value="0"<?= $this->feed->attributes('read_when_same_title_in_feed') === false ? ' selected="selected"' : '' ?>><?= _t('gen.short.no') ?></option>
+						<option value="10"<?= $this->feed->attributes('read_when_same_title_in_feed') == 10 ? ' selected="selected"' : '' ?>>10</option>
+						<option value="25"<?= $this->feed->attributes('read_when_same_title_in_feed') == 25 ? ' selected="selected"' : '' ?>>25</option>
+						<option value="100"<?= $this->feed->attributes('read_when_same_title_in_feed') == 100 ? ' selected="selected"' : '' ?>>100</option>
+						<option value="1000"<?= $this->feed->attributes('read_when_same_title_in_feed') == 1000 ? ' selected="selected"' : '' ?>>1 000</option>
+					</select>
+					<?= _t('conf.reading.read.when_same_title') ?>
+				</label>
+			</div>
+		</div>
+
+		<div class="form-group">
+			<label class="group-name" for="filteractions_read"><?= _t('conf.reading.read.when') ?></label>
+			<div class="group-controls">
+				<textarea name="filteractions_read" id="filteractions_read"><?php
+					foreach ($this->feed->filtersAction('read') as $filterRead) {
+						echo $filterRead->getRawInput(), PHP_EOL;
+					}
+				?></textarea>
+				<p class="help"><?= _i('help') ?> <?= _t('sub.feed.filteractions.help') ?></p>
+			</div>
+		</div>
+
+		<div class="form-group form-actions">
+			<div class="group-controls">
+				<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
+				<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
+			</div>
+		</div>
+
 		<legend><?= _t('sub.feed.archiving') ?></legend>
 
 		<div class="form-group">
@@ -220,43 +352,6 @@
 			</div>
 		</div>
 
-		<div class="form-group">
-			<label class="group-name" for="ttl"><?= _t('sub.feed.ttl') ?></label>
-			<div class="group-controls">
-				<select class="number" name="ttl" id="ttl" required="required"><?php
-					$found = false;
-					foreach (array(FreshRSS_Feed::TTL_DEFAULT => _t('gen.short.by_default'), 900 => '15min', 1200 => '20min', 1500 => '25min', 1800 => '30min', 2700 => '45min',
-							3600 => '1h', 5400 => '1.5h', 7200 => '2h', 10800 => '3h', 14400 => '4h', 18800 => '5h', 21600 => '6h', 25200 => '7h', 28800 => '8h',
-							36000 => '10h', 43200 => '12h', 64800 => '18h',
-							86400 => '1d', 129600 => '1.5d', 172800 => '2d', 259200 => '3d', 345600 => '4d', 432000 => '5d', 518400 => '6d',
-							604800 => '1wk', 1209600 => '2wk', 1814400 => '3wk', 2419200 => '4wk', 2629744 => '1mo') as $v => $t) {
-						echo '<option value="' . $v . ($this->feed->ttl() === $v ? '" selected="selected' : '') . '">' . $t . '</option>';
-						if ($this->feed->ttl() == $v) {
-							$found = true;
-						}
-					}
-					if (!$found) {
-						echo '<option value="' . intval($this->feed->ttl()) . '" selected="selected">' . intval($this->feed->ttl()) . 's</option>';
-					}
-				?></select>
-				<label for="mute">
-					<input type="checkbox" name="mute" id="mute" value="1"<?= $this->feed->mute() ? ' checked="checked"' : '' ?> />
-					<?= _t('sub.feed.mute') ?>
-				</label>
-			</div>
-		</div>
-
-		<?php if ($this->feed->pubSubHubbubEnabled()) { ?>
-			<div class="form-group">
-				<div class="group-controls">
-					<label class="checkbox" for="pubsubhubbub">
-						<input type="checkbox" name="pubsubhubbub" id="pubsubhubbub" disabled="disabled" value="1" checked="checked" />
-						<?= _t('sub.feed.websub') ?>
-					</label>
-				</div>
-			</div>
-		<?php } ?>
-
 		<div class="form-group form-actions">
 			<div class="group-controls">
 				<button class="btn btn-important"><?= _t('gen.action.submit') ?></button>
@@ -265,30 +360,6 @@
 			</div>
 		</div>
 
-		<legend><?= _t('sub.feed.auth.configuration') ?></legend>
-		<?php $auth = $this->feed->httpAuth(false); ?>
-		<div class="form-group">
-			<label class="group-name" for="http_user_feed<?= $this->feed->id() ?>"><?= _t('sub.feed.auth.username') ?></label>
-			<div class="group-controls">
-				<input type="text" name="http_user_feed<?= $this->feed->id() ?>" id="http_user_feed<?= $this->feed->id() ?>" class="extend" value="<?=
-					empty($auth['username']) ? ' ' : $auth['username'] ?>" autocomplete="off" />
-				<p class="help"><?= _i('help') ?> <?= _t('sub.feed.auth.help') ?></p>
-			</div>
-
-			<label class="group-name" for="http_pass_feed<?= $this->feed->id() ?>"><?= _t('sub.feed.auth.password') ?></label>
-			<div class="group-controls">
-				<input type="password" name="http_pass_feed<?= $this->feed->id() ?>" id="http_pass_feed<?= $this->feed->id() ?>" class="extend" value="<?=
-					$auth['password'] ?>" autocomplete="new-password" />
-			</div>
-		</div>
-
-		<div class="form-group form-actions">
-			<div class="group-controls">
-				<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
-				<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
-			</div>
-		</div>
-
 		<legend><?= _t('sub.feed.advanced') ?></legend>
 		<div class="form-group">
 			<label class="group-name" for="path_entries"><?= _t('sub.feed.css_path') ?></label>
@@ -328,43 +399,7 @@
 		</div>
 
 		<div class="form-group">
-			<label class="group-name" for="mark_updated_article_unread"><?= _t('conf.reading.mark_updated_article_unread') ?></label>
-			<div class="group-controls">
-				<label class="checkbox" for="mark_updated_article_unread">
-					<select name="mark_updated_article_unread" id="mark_updated_article_unread">
-						<option value=""<?= $this->feed->attributes('mark_updated_article_unread') === null ? ' selected="selected"' : '' ?>><?= _t('gen.short.by_default') ?></option>
-						<option value="0"<?= $this->feed->attributes('mark_updated_article_unread') === false ? ' selected="selected"' : '' ?>><?= _t('gen.short.no') ?></option>
-						<option value="1"<?= $this->feed->attributes('mark_updated_article_unread') === true ? ' selected="selected"' : '' ?>><?= _t('gen.short.yes') ?></option>
-					</select>
-				</label>
-			</div>
-		</div>
-
-		<div class="form-group">
-			<label class="group-name" for="read_upon_reception"><?= _t('conf.reading.read.when') ?></label>
-			<div class="group-controls">
-				<label class="checkbox" for="read_upon_reception">
-					<select name="read_upon_reception" id="read_upon_reception">
-						<option value=""<?= $this->feed->attributes('read_upon_reception') === null ? ' selected="selected"' : '' ?>><?= _t('gen.short.by_default') ?></option>
-						<option value="0"<?= $this->feed->attributes('read_upon_reception') === false ? ' selected="selected"' : '' ?>><?= _t('gen.short.no') ?></option>
-						<option value="1"<?= $this->feed->attributes('read_upon_reception') === true ? ' selected="selected"' : '' ?>><?= _t('gen.short.yes') ?></option>
-					</select>
-					<?= _t('conf.reading.read.upon_reception') ?>
-				</label>
-			</div>
-		</div>
-
-		<div class="form-group">
-			<div class="group-controls">
-				<label class="checkbox" for="clear_cache">
-					<input type="checkbox" name="clear_cache" id="clear_cache" value="1"<?= $this->feed->attributes('clear_cache') ? ' checked="checked"' : '' ?> />
-					<?= _t('sub.feed.clear_cache') ?>
-				</label>
-			</div>
-		</div>
-
-		<div class="form-group">
-			<label class="group-name" for="path_entries"><?= _t('sub.feed.useragent') ?></label>
+			<label class="group-name" for="curl_params_useragent"><?= _t('sub.feed.useragent') ?></label>
 			<div class="group-controls">
 				<div class="stick">
 					<input type="text" name="curl_params_useragent" id="curl_params_useragent" class="extend" value="<?=
@@ -377,7 +412,7 @@
 		</div>
 
 		<div class="form-group">
-			<label class="group-name" for="path_entries"><?= _t('sub.feed.proxy') ?></label>
+			<label class="group-name" for="proxy_type"><?= _t('sub.feed.proxy') ?></label>
 			<div class="group-controls">
 				<select class="number" name="proxy_type" id="proxy_type"><?php
 					$type = '';
@@ -419,22 +454,18 @@
 			</div>
 		</div>
 
-		<legend><?= _t('sub.feed.filteractions') ?></legend>
 		<div class="form-group">
-			<label class="group-name" for="filteractions_read"><?= _t('conf.reading.read.when') ?></label>
 			<div class="group-controls">
-				<textarea name="filteractions_read" id="filteractions_read"><?php
-					foreach ($this->feed->filtersAction('read') as $filterRead) {
-						echo $filterRead->getRawInput(), PHP_EOL;
-					}
-				?></textarea>
-				<p class="help"><?= _i('help') ?> <?= _t('sub.feed.filteractions.help') ?></p>
+				<label class="checkbox" for="clear_cache">
+					<input type="checkbox" name="clear_cache" id="clear_cache" value="1"<?= $this->feed->attributes('clear_cache') ? ' checked="checked"' : '' ?> />
+					<?= _t('sub.feed.clear_cache') ?>
+				</label>
 			</div>
 		</div>
 
 		<div class="form-group form-actions">
 			<div class="group-controls">
-				<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
+				<button class="btn btn-important"><?= _t('gen.action.submit') ?></button>
 				<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
 			</div>
 		</div>

+ 2 - 0
cli/i18n/ignore/en-us.php

@@ -224,9 +224,11 @@ return array(
 	'conf.reading.number_divided_when_reader',
 	'conf.reading.read.article_open_on_website',
 	'conf.reading.read.article_viewed',
+	'conf.reading.read.keep_max_n_unread',
 	'conf.reading.read.scroll',
 	'conf.reading.read.upon_reception',
 	'conf.reading.read.when',
+	'conf.reading.read.when_same_title',
 	'conf.reading.show._',
 	'conf.reading.show.active_category',
 	'conf.reading.show.adaptive',

+ 4 - 2
config-user.default.php

@@ -49,9 +49,11 @@ return array (
 	'anon_access' => false,
 	'mark_when' => array (
 		'article' => true,
-		'site' => true,
-		'scroll' => true,
+		'max_n_unread' => false,
 		'reception' => false,
+		'same_title_in_feed' => false,
+		'scroll' => true,
+		'site' => true,
 	),
 	'theme' => 'Origine',
 	'content_width' => 'thin',