aboutsummaryrefslogtreecommitdiff
path: root/app/Models
diff options
context:
space:
mode:
authorGravatar Alexandre Alapetite <alexandre@alapetite.fr> 2022-07-04 09:53:26 +0200
committerGravatar GitHub <noreply@github.com> 2022-07-04 09:53:26 +0200
commit509c8cae6381ec46af7c8303eb92fda6ce496a4a (patch)
tree653f7f44df842f9d7135decd89467879a0098c50 /app/Models
parent57d571230eeb2d3ede57e640b640f17c7a2298a2 (diff)
Dynamic OPML (#4407)
* Dynamic OPML draft #fix https://github.com/FreshRSS/FreshRSS/issues/4191 * Export dynamic OPML http://opml.org/spec2.opml#1629043127000 * Restart with simpler approach * Minor revert * Export dynamic OPML also for single feeds * Special category type for importing dynamic OPML * Parameter for excludeMutedFeeds * Details * More draft * i18n * Fix update * Draft manual import working * Working manual refresh * Draft automatic update * Working Web refresh + fixes * Import/export dynamic OPML settings * Annoying numerous lines in SQL logs * Fix minor JavaScript error * Fix auto adding new columns * Add require * Add missing πŸ—² * Missing space * Disable adding new feeds to dynamic categories * Link from import * i18n typo * Improve theme icon function * Fix pink-dark
Diffstat (limited to 'app/Models')
-rw-r--r--app/Models/Category.php136
-rw-r--r--app/Models/CategoryDAO.php103
-rw-r--r--app/Models/CategoryDAOSQLite.php2
-rw-r--r--app/Models/Context.php17
-rw-r--r--app/Models/Entry.php3
-rw-r--r--app/Models/Feed.php35
-rw-r--r--app/Models/FeedDAO.php88
-rw-r--r--app/Models/Themes.php41
-rw-r--r--app/Models/UserConfiguration.php1
-rw-r--r--app/Models/View.php2
10 files changed, 350 insertions, 78 deletions
diff --git a/app/Models/Category.php b/app/Models/Category.php
index b67818e19..d75d7e21e 100644
--- a/app/Models/Category.php
+++ b/app/Models/Category.php
@@ -1,17 +1,38 @@
<?php
class FreshRSS_Category extends Minz_Model {
+
+ /**
+ * Normal
+ * @var int
+ */
+ const KIND_NORMAL = 0;
+
+ /**
+ * Category tracking a third-party Dynamic OPML
+ * @var int
+ */
+ const KIND_DYNAMIC_OPML = 2;
+
+ const TTL_DEFAULT = 0;
+
/**
* @var int
*/
private $id = 0;
+ /** @var int */
+ private $kind = 0;
private $name;
private $nbFeeds = -1;
private $nbNotRead = -1;
+ /** @var array<FreshRSS_Feed>|null */
private $feeds = null;
private $hasFeedsWithError = false;
- private $isDefault = false;
private $attributes = [];
+ /** @var int */
+ private $lastUpdate = 0;
+ /** @var bool */
+ private $error = false;
public function __construct(string $name = '', $feeds = null) {
$this->_name($name);
@@ -30,11 +51,26 @@ class FreshRSS_Category extends Minz_Model {
public function id(): int {
return $this->id;
}
+ public function kind(): int {
+ return $this->kind;
+ }
public function name(): string {
return $this->name;
}
+ public function lastUpdate(): int {
+ return $this->lastUpdate;
+ }
+ public function _lastUpdate(int $value) {
+ $this->lastUpdate = $value;
+ }
+ public function inError(): bool {
+ return $this->error;
+ }
+ public function _error($value) {
+ $this->error = (bool)$value;
+ }
public function isDefault(): bool {
- return $this->isDefault;
+ return $this->id == FreshRSS_CategoryDAO::DEFAULTCATEGORYID;
}
public function nbFeeds(): int {
if ($this->nbFeeds < 0) {
@@ -52,6 +88,8 @@ class FreshRSS_Category extends Minz_Model {
return $this->nbNotRead;
}
+
+ /** @return array<FreshRSS_Feed> */
public function feeds(): array {
if ($this->feeds === null) {
$feedDAO = FreshRSS_Factory::createFeedDao();
@@ -90,12 +128,15 @@ class FreshRSS_Category extends Minz_Model {
$this->_name(_t('gen.short.default_category'));
}
}
+
+ public function _kind(int $kind) {
+ $this->kind = $kind;
+ }
+
public function _name($value) {
$this->name = mb_strcut(trim($value), 0, 255, 'UTF-8');
}
- public function _isDefault($value) {
- $this->isDefault = $value;
- }
+ /** @param array<FreshRSS_Feed>|FreshRSS_Feed $values */
public function _feeds($values) {
if (!is_array($values)) {
$values = array($values);
@@ -104,6 +145,17 @@ class FreshRSS_Category extends Minz_Model {
$this->feeds = $values;
}
+ /**
+ * To manually add feeds to this category (not committing to database).
+ * @param FreshRSS_Feed $feed
+ */
+ public function addFeed($feed) {
+ if ($this->feeds === null) {
+ $this->feeds = [];
+ }
+ $this->feeds[] = $feed;
+ }
+
public function _attributes($key, $value) {
if ('' == $key) {
if (is_string($value)) {
@@ -118,4 +170,78 @@ class FreshRSS_Category extends Minz_Model {
$this->attributes[$key] = $value;
}
}
+
+ public static function cacheFilename(string $url, array $attributes): string {
+ $simplePie = customSimplePie($attributes);
+ $filename = $simplePie->get_cache_filename($url);
+ return CACHE_PATH . '/' . $filename . '.opml.xml';
+ }
+
+ public function refreshDynamicOpml(): bool {
+ $url = $this->attributes('opml_url');
+ if ($url == '') {
+ return false;
+ }
+ $ok = true;
+ $attributes = []; //TODO
+ $cachePath = self::cacheFilename($url, $attributes);
+ $opml = httpGet($url, $cachePath, 'opml', $attributes);
+ if ($opml == '') {
+ Minz_Log::warning('Error getting dynamic OPML for category ' . $this->id() . '! ' .
+ SimplePie_Misc::url_remove_credentials($url));
+ $ok = false;
+ } else {
+ $dryRunCategory = new FreshRSS_Category();
+ $importService = new FreshRSS_Import_Service();
+ $importService->importOpml($opml, $dryRunCategory, true, true);
+ if ($importService->lastStatus()) {
+ $feedDAO = FreshRSS_Factory::createFeedDao();
+
+ /** @var array<string,FreshRSS_Feed> */
+ $dryRunFeeds = [];
+ foreach ($dryRunCategory->feeds() as $dryRunFeed) {
+ $dryRunFeeds[$dryRunFeed->url()] = $dryRunFeed;
+ }
+
+ /** @var array<string,FreshRSS_Feed> */
+ $existingFeeds = [];
+ foreach ($this->feeds() as $existingFeed) {
+ $existingFeeds[$existingFeed->url()] = $existingFeed;
+ if (empty($dryRunFeeds[$existingFeed->url()])) {
+ // The feed does not exist in the new dynamic OPML, so mute (disable) that feed
+ $existingFeed->_mute(true);
+ $ok &= ($feedDAO->updateFeed($existingFeed->id(), [
+ 'ttl' => $existingFeed->ttl(true),
+ ]) !== false);
+ }
+ }
+
+ foreach ($dryRunCategory->feeds() as $dryRunFeed) {
+ if (empty($existingFeeds[$dryRunFeed->url()])) {
+ // The feed does not exist in the current category, so add that feed
+ $dryRunFeed->_category($this->id());
+ $ok &= ($feedDAO->addFeedObject($dryRunFeed) !== false);
+ } else {
+ $existingFeed = $existingFeeds[$dryRunFeed->url()];
+ if ($existingFeed->mute()) {
+ // The feed already exists in the current category but was muted (disabled), so unmute (enable) again
+ $existingFeed->_mute(false);
+ $ok &= ($feedDAO->updateFeed($existingFeed->id(), [
+ 'ttl' => $existingFeed->ttl(true),
+ ]) !== false);
+ }
+ }
+ }
+ } else {
+ $ok = false;
+ Minz_Log::warning('Error loading dynamic OPML for category ' . $this->id() . '! ' .
+ SimplePie_Misc::url_remove_credentials($url));
+ }
+ }
+
+ $catDAO = FreshRSS_Factory::createCategoryDao();
+ $catDAO->updateLastUpdate($this->id(), !$ok);
+
+ return $ok;
+ }
}
diff --git a/app/Models/CategoryDAO.php b/app/Models/CategoryDAO.php
index 18747c906..cef8e6d63 100644
--- a/app/Models/CategoryDAO.php
+++ b/app/Models/CategoryDAO.php
@@ -17,7 +17,13 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
protected function addColumn($name) {
Minz_Log::warning(__method__ . ': ' . $name);
try {
- if ('attributes' === $name) { //v1.15.0
+ if ($name === 'kind') { //v1.20.0
+ return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN kind SMALLINT DEFAULT 0') !== false;
+ } elseif ($name === 'lastUpdate') { //v1.20.0
+ return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN `lastUpdate` BIGINT DEFAULT 0') !== false;
+ } elseif ($name === 'error') { //v1.20.0
+ return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN error SMALLINT DEFAULT 0') !== false;
+ } elseif ('attributes' === $name) { //v1.15.0
$ok = $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN attributes TEXT') !== false;
$stm = $this->pdo->query('SELECT * FROM `_feed`');
@@ -69,8 +75,9 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
protected function autoUpdateDb(array $errorInfo) {
if (isset($errorInfo[0])) {
if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
- foreach (['attributes'] as $column) {
- if (stripos($errorInfo[2], $column) !== false) {
+ $errorLines = explode("\n", $errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise
+ foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) {
+ if (stripos($errorLines[0], $column) !== false) {
return $this->addColumn($column);
}
}
@@ -79,12 +86,13 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
return false;
}
+ /** @return int|false */
public function addCategory($valuesTmp) {
// TRIM() to provide a type hint as text
// No tag of the same name
$sql = <<<'SQL'
-INSERT INTO `_category`(name, attributes)
-SELECT * FROM (SELECT TRIM(?) AS name, TRIM(?) AS attributes) c2
+INSERT INTO `_category`(kind, name, attributes)
+SELECT * FROM (SELECT ABS(?) AS kind, TRIM(?) AS name, TRIM(?) AS attributes) c2
WHERE NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = TRIM(?))
SQL;
$stm = $this->pdo->prepare($sql);
@@ -94,6 +102,7 @@ SQL;
$valuesTmp['attributes'] = [];
}
$values = array(
+ $valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL,
$valuesTmp['name'],
is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES),
$valuesTmp['name'],
@@ -111,13 +120,18 @@ SQL;
}
}
+ /**
+ * @param FreshRSS_Category $category
+ * @return int|false
+ */
public function addCategoryObject($category) {
$cat = $this->searchByName($category->name());
if (!$cat) {
- // Category does not exist yet in DB so we add it before continue
- $values = array(
+ $values = [
+ 'kind' => $category->kind(),
'name' => $category->name(),
- );
+ 'attributes' => $category->attributes(),
+ ];
return $this->addCategory($values);
}
@@ -127,7 +141,7 @@ SQL;
public function updateCategory($id, $valuesTmp) {
// No tag of the same name
$sql = <<<'SQL'
-UPDATE `_category` SET name=?, attributes=? WHERE id=?
+UPDATE `_category` SET name=?, kind=?, attributes=? WHERE id=?
AND NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = ?)
SQL;
$stm = $this->pdo->prepare($sql);
@@ -138,6 +152,7 @@ SQL;
}
$values = array(
$valuesTmp['name'],
+ $valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL,
is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES),
$id,
$valuesTmp['name'],
@@ -155,6 +170,24 @@ SQL;
}
}
+ public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0) {
+ $sql = 'UPDATE `_category` SET `lastUpdate`=?, error=? WHERE id=?';
+ $values = [
+ $mtime <= 0 ? time() : $mtime,
+ $inError ? 1 : 0,
+ $id,
+ ];
+ $stm = $this->pdo->prepare($sql);
+
+ if ($stm && $stm->execute($values)) {
+ return $stm->rowCount();
+ } else {
+ $info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
+ Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
+ return false;
+ }
+ }
+
public function deleteCategory($id) {
if ($id <= self::DEFAULTCATEGORYID) {
return false;
@@ -172,7 +205,7 @@ SQL;
}
public function selectAll() {
- $sql = 'SELECT id, name, attributes FROM `_category`';
+ $sql = 'SELECT id, name, kind, `lastUpdate`, error, attributes FROM `_category`';
$stm = $this->pdo->query($sql);
if ($stm != false) {
while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
@@ -181,15 +214,14 @@ SQL;
} else {
$info = $this->pdo->errorInfo();
if ($this->autoUpdateDb($info)) {
- foreach ($this->selectAll() as $category) { // `yield from` requires PHP 7+
- yield $category;
- }
+ yield from $this->selectAll();
}
Minz_Log::error(__method__ . ' error: ' . json_encode($info));
yield false;
}
}
+ /** @return FreshRSS_Category|null */
public function searchById($id) {
$sql = 'SELECT * FROM `_category` WHERE id=:id';
$stm = $this->pdo->prepare($sql);
@@ -204,7 +236,9 @@ SQL;
return null;
}
}
- public function searchByName($name) {
+
+ /** @return FreshRSS_Category|null|false */
+ public function searchByName(string $name) {
$sql = 'SELECT * FROM `_category` WHERE name=:name';
$stm = $this->pdo->prepare($sql);
if ($stm == false) {
@@ -246,7 +280,7 @@ SQL;
public function listCategories($prePopulateFeeds = true, $details = false) {
if ($prePopulateFeeds) {
- $sql = 'SELECT c.id AS c_id, c.name AS c_name, c.attributes AS c_attributes, '
+ $sql = 'SELECT c.id AS c_id, c.name AS c_name, c.kind AS c_kind, c.`lastUpdate` AS c_last_update, c.error AS c_error, c.attributes AS c_attributes, '
. ($details ? 'f.* ' : 'f.id, f.name, f.url, f.website, f.priority, f.error, f.`cache_nbEntries`, f.`cache_nbUnreads`, f.ttl ')
. 'FROM `_category` c '
. 'LEFT OUTER JOIN `_feed` f ON f.category=c.id '
@@ -272,6 +306,27 @@ SQL;
}
}
+ /** @return array<FreshRSS_Category> */
+ public function listCategoriesOrderUpdate(int $defaultCacheDuration = 86400, int $limit = 0) {
+ $sql = 'SELECT * FROM `_category` WHERE kind = :kind AND `lastUpdate` < :lu ORDER BY `lastUpdate`'
+ . ($limit < 1 ? '' : ' LIMIT ' . intval($limit));
+ $stm = $this->pdo->prepare($sql);
+ if ($stm &&
+ $stm->bindValue(':kind', FreshRSS_Category::KIND_DYNAMIC_OPML, PDO::PARAM_INT) &&
+ $stm->bindValue(':lu', time() - $defaultCacheDuration, PDO::PARAM_INT) &&
+ $stm->execute()) {
+ return self::daoToCategory($stm->fetchAll(PDO::FETCH_ASSOC));
+ } else {
+ $info = $stm ? $stm->errorInfo() : $this->pdo->errorInfo();
+ if ($this->autoUpdateDb($info)) {
+ return $this->listCategoriesOrderUpdate($defaultCacheDuration, $limit);
+ }
+ Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
+ return [];
+ }
+ }
+
+ /** @return FreshRSS_Category|null */
public function getDefault() {
$sql = 'SELECT * FROM `_category` WHERE id=:id';
$stm = $this->pdo->prepare($sql);
@@ -290,6 +345,8 @@ SQL;
return null;
}
}
+
+ /** @return int|bool */
public function checkDefault() {
$def_cat = $this->searchById(self::DEFAULTCATEGORYID);
@@ -345,6 +402,10 @@ SQL;
return $res[0]['count'];
}
+ /**
+ * @param array<FreshRSS_Category> $categories
+ * @param int $feed_id
+ */
public static function findFeed($categories, $feed_id) {
foreach ($categories as $category) {
foreach ($category->feeds() as $feed) {
@@ -356,6 +417,10 @@ SQL;
return null;
}
+ /**
+ * @param array<FreshRSS_Category> $categories
+ * @param int $minPriority
+ */
public static function CountUnreads($categories, $minPriority = 0) {
$n = 0;
foreach ($categories as $category) {
@@ -386,6 +451,7 @@ SQL;
$feedDao->daoToFeed($feedsDao, $previousLine['c_id'])
);
$cat->_id($previousLine['c_id']);
+ $cat->_kind($previousLine['c_kind']);
$cat->_attributes('', $previousLine['c_attributes']);
$list[$previousLine['c_id']] = $cat;
@@ -403,6 +469,9 @@ SQL;
$feedDao->daoToFeed($feedsDao, $previousLine['c_id'])
);
$cat->_id($previousLine['c_id']);
+ $cat->_kind($previousLine['c_kind']);
+ $cat->_lastUpdate($previousLine['c_last_update'] ?? 0);
+ $cat->_error($previousLine['c_error'] ?? false);
$cat->_attributes('', $previousLine['c_attributes']);
$list[$previousLine['c_id']] = $cat;
}
@@ -422,8 +491,10 @@ SQL;
$dao['name']
);
$cat->_id($dao['id']);
+ $cat->_kind($dao['kind']);
+ $cat->_lastUpdate($dao['lastUpdate'] ?? 0);
+ $cat->_error($dao['error'] ?? false);
$cat->_attributes('', isset($dao['attributes']) ? $dao['attributes'] : '');
- $cat->_isDefault(static::DEFAULTCATEGORYID === intval($dao['id']));
$list[$key] = $cat;
}
diff --git a/app/Models/CategoryDAOSQLite.php b/app/Models/CategoryDAOSQLite.php
index 6f200be6d..363ffb427 100644
--- a/app/Models/CategoryDAOSQLite.php
+++ b/app/Models/CategoryDAOSQLite.php
@@ -5,7 +5,7 @@ class FreshRSS_CategoryDAOSQLite extends FreshRSS_CategoryDAO {
protected function autoUpdateDb(array $errorInfo) {
if ($tableInfo = $this->pdo->query("PRAGMA table_info('category')")) {
$columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1);
- foreach (['attributes'] as $column) {
+ foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) {
if (!in_array($column, $columns)) {
return $this->addColumn($column);
}
diff --git a/app/Models/Context.php b/app/Models/Context.php
index 55607f5c4..ab855966b 100644
--- a/app/Models/Context.php
+++ b/app/Models/Context.php
@@ -198,6 +198,20 @@ class FreshRSS_Context {
}
/**
+ * @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;
+ }
+
+ /**
+ * @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 {
@@ -251,8 +265,7 @@ class FreshRSS_Context {
*/
public static function _get($get) {
$type = $get[0];
- $id = substr($get, 2);
- $nb_unread = 0;
+ $id = intval(substr($get, 2));
if (empty(self::$categories)) {
$catDAO = FreshRSS_Factory::createCategoryDao();
diff --git a/app/Models/Entry.php b/app/Models/Entry.php
index 57b0e0b60..fb17268b3 100644
--- a/app/Models/Entry.php
+++ b/app/Models/Entry.php
@@ -488,7 +488,8 @@ class FreshRSS_Entry extends Minz_Model {
* @param array<string,mixed> $attributes
*/
public static function getContentByParsing(string $url, string $path, array $attributes = [], int $maxRedirs = 3): string {
- $html = getHtml($url, $attributes);
+ $cachePath = FreshRSS_Feed::cacheFilename($url, $attributes, FreshRSS_Feed::KIND_HTML_XPATH);
+ $html = httpGet($url, $cachePath, 'html', $attributes);
if (strlen($html) > 0) {
$doc = new DOMDocument();
$doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
diff --git a/app/Models/Feed.php b/app/Models/Feed.php
index 6f6b83af0..e39109b49 100644
--- a/app/Models/Feed.php
+++ b/app/Models/Feed.php
@@ -162,9 +162,21 @@ class FreshRSS_Feed extends Minz_Model {
public function inError(): bool {
return $this->error;
}
- public function ttl(): int {
+
+ /**
+ * @param bool $raw true for database version combined with mute information, false otherwise
+ */
+ public function ttl(bool $raw = false): int {
+ if ($raw) {
+ $ttl = $this->ttl;
+ if ($this->mute && FreshRSS_Feed::TTL_DEFAULT === $ttl) {
+ $ttl = FreshRSS_Context::$user_conf ? FreshRSS_Context::$user_conf->ttl_default : 3600;
+ }
+ return $ttl * ($this->mute ? -1 : 1);
+ }
return $this->ttl;
}
+
public function attributes($key = '') {
if ($key == '') {
return $this->attributes;
@@ -172,19 +184,11 @@ class FreshRSS_Feed extends Minz_Model {
return isset($this->attributes[$key]) ? $this->attributes[$key] : null;
}
}
+
public function mute(): bool {
return $this->mute;
}
- // public function ttlExpire() {
- // $ttl = $this->ttl;
- // if ($ttl == self::TTL_DEFAULT) { //Default
- // $ttl = FreshRSS_Context::$user_conf->ttl_default;
- // }
- // if ($ttl == -1) { //Never
- // $ttl = 64000000; //~2 years. Good enough for PubSubHubbub logic
- // }
- // return $this->lastUpdate + $ttl;
- // }
+
public function nbEntries(): int {
if ($this->nbEntries < 0) {
$feedDAO = FreshRSS_Factory::createFeedDao();
@@ -248,10 +252,13 @@ class FreshRSS_Feed extends Minz_Model {
public function _kind(int $value) {
$this->kind = $value;
}
+
+ /** @param int $value */
public function _category($value) {
$value = intval($value);
$this->category = $value >= 0 ? $value : 0;
}
+
public function _name(string $value) {
$this->name = $value == '' ? '' : trim($value);
}
@@ -282,6 +289,9 @@ class FreshRSS_Feed extends Minz_Model {
public function _error($value) {
$this->error = (bool)$value;
}
+ public function _mute(bool $value) {
+ $this->mute = $value;
+ }
public function _ttl($value) {
$value = intval($value);
$value = min($value, 100000000);
@@ -584,7 +594,8 @@ class FreshRSS_Feed extends Minz_Model {
return null;
}
- $html = getHtml($feedSourceUrl, $attributes);
+ $cachePath = FreshRSS_Feed::cacheFilename($feedSourceUrl, $attributes, FreshRSS_Feed::KIND_HTML_XPATH);
+ $html = httpGet($feedSourceUrl, $cachePath, 'html', $attributes);
if (strlen($html) <= 0) {
return null;
}
diff --git a/app/Models/FeedDAO.php b/app/Models/FeedDAO.php
index ec507b324..8d54e7be2 100644
--- a/app/Models/FeedDAO.php
+++ b/app/Models/FeedDAO.php
@@ -19,8 +19,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
protected function autoUpdateDb(array $errorInfo) {
if (isset($errorInfo[0])) {
if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
+ $errorLines = explode("\n", $errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise
foreach (['attributes', 'kind'] as $column) {
- if (stripos($errorInfo[2], $column) !== false) {
+ if (stripos($errorLines[0], $column) !== false) {
return $this->addColumn($column);
}
}
@@ -29,26 +30,10 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
return false;
}
+ /** @return int|false */
public function addFeed(array $valuesTmp) {
- $sql = '
- INSERT INTO `_feed`
- (
- url,
- kind,
- category,
- name,
- website,
- description,
- `lastUpdate`,
- priority,
- `pathEntries`,
- `httpAuth`,
- error,
- ttl,
- attributes
- )
- VALUES
- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
+ $sql = 'INSERT INTO `_feed` (url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
$stm = $this->pdo->prepare($sql);
$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
@@ -88,10 +73,8 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
}
}
- public function addFeedObject(FreshRSS_Feed $feed): int {
- // TODO: not sure if we should write this method in DAO since DAO
- // should not be aware about feed class
-
+ /** @return int|false */
+ public function addFeedObject(FreshRSS_Feed $feed) {
// Add feed only if we don’t find it in DB
$feed_search = $this->searchByUrl($feed->url());
if (!$feed_search) {
@@ -106,13 +89,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
'lastUpdate' => 0,
'pathEntries' => $feed->pathEntries(),
'httpAuth' => $feed->httpAuth(),
+ 'ttl' => $feed->ttl(true),
'attributes' => $feed->attributes(),
);
- if ($feed->mute() || (
- FreshRSS_Context::$user_conf != null && //When creating a new user
- $feed->ttl() != FreshRSS_Context::$user_conf->ttl_default)) {
- $values['ttl'] = $feed->ttl() * ($feed->mute() ? -1 : 1);
- }
$id = $this->addFeed($values);
if ($id) {
@@ -121,11 +100,36 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
}
return $id;
- }
+ } else {
+ // The feed already exists so make sure it is not muted
+ $feed->_ttl($feed_search->ttl());
+ $feed->_mute(false);
+
+ // Merge existing and import attributes
+ $existingAttributes = $feed_search->attributes();
+ $importAttributes = $feed->attributes();
+ $feed->_attributes('', array_merge_recursive($existingAttributes, $importAttributes));
+
+ // Update some values of the existing feed using the import
+ $values = [
+ 'kind' => $feed->kind(),
+ 'name' => $feed->name(),
+ 'website' => $feed->website(),
+ 'description' => $feed->description(),
+ 'pathEntries' => $feed->pathEntries(),
+ 'ttl' => $feed->ttl(true),
+ 'attributes' => $feed->attributes(),
+ ];
+
+ if (!$this->updateFeed($feed_search->id(), $values)) {
+ return false;
+ }
- return $feed_search->id();
+ return $feed_search->id();
+ }
}
+ /** @return int|false */
public function updateFeed(int $id, array $valuesTmp) {
if (isset($valuesTmp['name'])) {
$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
@@ -193,7 +197,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
- Minz_Log::error('SQL error updateLastUpdate: ' . $info[2]);
+ Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
return false;
}
}
@@ -227,6 +231,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
}
}
+ /** @return int|false */
public function deleteFeed(int $id) {
$sql = 'DELETE FROM `_feed` WHERE id=?';
$stm = $this->pdo->prepare($sql);
@@ -241,8 +246,16 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
return false;
}
}
- public function deleteFeedByCategory(int $id) {
+
+ /**
+ * @param bool|null $muted to include only muted feeds
+ * @return int|false
+ */
+ public function deleteFeedByCategory(int $id, $muted = null) {
$sql = 'DELETE FROM `_feed` WHERE category=?';
+ if ($muted) {
+ $sql .= ' AND ttl < 0';
+ }
$stm = $this->pdo->prepare($sql);
$values = array($id);
@@ -349,6 +362,7 @@ SQL;
/**
* Use $defaultCacheDuration == -1 to return all feeds, without filtering them by TTL.
+ * @return array<FreshRSS_Feed>
*/
public function listFeedsOrderUpdate(int $defaultCacheDuration = 3600, int $limit = 0) {
$this->updateTTL();
@@ -365,7 +379,7 @@ SQL;
} else {
$info = $this->pdo->errorInfo();
if ($this->autoUpdateDb($info)) {
- return $this->listFeedsOrderUpdate($defaultCacheDuration);
+ return $this->listFeedsOrderUpdate($defaultCacheDuration, $limit);
}
Minz_Log::error('SQL error listFeedsOrderUpdate: ' . $info[2]);
return array();
@@ -386,10 +400,14 @@ SQL;
}
/**
+ * @param bool|null $muted to include only muted feeds
* @return array<FreshRSS_Feed>
*/
- public function listByCategory(int $cat): array {
+ public function listByCategory(int $cat, $muted = null): array {
$sql = 'SELECT * FROM `_feed` WHERE category=?';
+ if ($muted) {
+ $sql .= ' AND ttl < 0';
+ }
$stm = $this->pdo->prepare($sql);
$stm->execute(array($cat));
diff --git a/app/Models/Themes.php b/app/Models/Themes.php
index ceaa49266..a59f4b663 100644
--- a/app/Models/Themes.php
+++ b/app/Models/Themes.php
@@ -68,6 +68,13 @@ class FreshRSS_Themes extends Minz_Model {
return $infos;
}
+ public static function title($name) {
+ static $titles = [
+ 'opml-dyn' => 'sub.category.dynamic_opml',
+ ];
+ return $titles[$name] ?? '';
+ }
+
public static function alt($name) {
static $alts = array(
'add' => 'βž•', //✚
@@ -94,6 +101,7 @@ class FreshRSS_Themes extends Minz_Model {
'next' => '⏩',
'non-starred' => 'β˜†',
'notice' => 'ℹ️', //β“˜
+ 'opml-dyn' => 'πŸ—²',
'prev' => 'βͺ',
'read' => 'β˜‘οΈ', //β˜‘
'rss' => 'πŸ“£', //β˜„
@@ -115,7 +123,13 @@ class FreshRSS_Themes extends Minz_Model {
return isset($name) ? $alts[$name] : '';
}
- public static function icon($name, $urlOnly = false) {
+ // TODO: Change for enum in PHP 8.1+
+ const ICON_DEFAULT = 0;
+ const ICON_IMG = 1;
+ const ICON_URL = 2;
+ const ICON_EMOJI = 3;
+
+ public static function icon(string $name, int $type = self::ICON_DEFAULT): string {
$alt = self::alt($name);
if ($alt == '') {
return '';
@@ -124,14 +138,29 @@ class FreshRSS_Themes extends Minz_Model {
$url = $name . '.svg';
$url = isset(self::$themeIcons[$url]) ? (self::$themeIconsUrl . $url) : (self::$defaultIconsUrl . $url);
- if ($urlOnly) {
- return Minz_Url::display($url);
+ $title = self::title($name);
+ if ($title != '') {
+ $title = ' title="' . _t($title) . '"';
}
- if (FreshRSS_Context::$user_conf && FreshRSS_Context::$user_conf->icons_as_emojis) {
- return '<span class="icon">' . $alt . '</span>';
+ if ($type == self::ICON_DEFAULT) {
+ if ((FreshRSS_Context::$user_conf && FreshRSS_Context::$user_conf->icons_as_emojis) ||
+ // default to emoji alternate for some icons
+ in_array($name, [ 'opml-dyn' ])) {
+ $type = self::ICON_EMOJI;
+ } else {
+ $type = self::ICON_IMG;
+ }
}
- return '<img class="icon" src="' . Minz_Url::display($url) . '" loading="lazy" alt="' . $alt . '" />';
+ switch ($type) {
+ case self::ICON_URL:
+ return Minz_Url::display($url);
+ case self::ICON_IMG:
+ return '<img class="icon" src="' . Minz_Url::display($url) . '" loading="lazy" alt="' . $alt . '"' . $title . ' />';
+ case self::ICON_EMOJI:
+ default:
+ return '<span class="icon"' . $title . '>' . $alt . '</span>';
+ }
}
}
diff --git a/app/Models/UserConfiguration.php b/app/Models/UserConfiguration.php
index 96fc77b59..45ec12e5a 100644
--- a/app/Models/UserConfiguration.php
+++ b/app/Models/UserConfiguration.php
@@ -57,6 +57,7 @@
* @property bool $topline_summary
* @property string $topline_thumbnail
* @property int $ttl_default
+ * @property int $dynamic_opml_ttl_default
* @property-read bool $unsafe_autologin_enabled
* @property string $view_mode
* @property array<string,mixed> $volatile
diff --git a/app/Models/View.php b/app/Models/View.php
index a46ebd95e..0169f130a 100644
--- a/app/Models/View.php
+++ b/app/Models/View.php
@@ -25,6 +25,8 @@ class FreshRSS_View extends Minz_View {
public $tags;
/** @var array<string,string> */
public $notification;
+ /** @var bool */
+ public $excludeMutedFeeds;
// Substriptions
public $default_category;