diff options
Diffstat (limited to 'app/Models')
| -rw-r--r-- | app/Models/Category.php | 6 | ||||
| -rw-r--r-- | app/Models/CategoryDAO.php | 17 | ||||
| -rw-r--r-- | app/Models/Context.php | 46 | ||||
| -rw-r--r-- | app/Models/DatabaseDAO.php | 89 | ||||
| -rw-r--r-- | app/Models/DatabaseDAOPGSQL.php | 38 | ||||
| -rw-r--r-- | app/Models/DatabaseDAOSQLite.php | 14 | ||||
| -rw-r--r-- | app/Models/Entry.php | 53 | ||||
| -rw-r--r-- | app/Models/EntryDAO.php | 103 | ||||
| -rw-r--r-- | app/Models/EntryDAOPGSQL.php | 9 | ||||
| -rw-r--r-- | app/Models/EntryDAOSQLite.php | 48 | ||||
| -rw-r--r-- | app/Models/Factory.php | 16 | ||||
| -rw-r--r-- | app/Models/Feed.php | 34 | ||||
| -rw-r--r-- | app/Models/FeedDAO.php | 9 | ||||
| -rw-r--r-- | app/Models/Search.php | 37 | ||||
| -rw-r--r-- | app/Models/Tag.php | 76 | ||||
| -rw-r--r-- | app/Models/TagDAO.php | 315 | ||||
| -rw-r--r-- | app/Models/TagDAOPGSQL.php | 9 | ||||
| -rw-r--r-- | app/Models/TagDAOSQLite.php | 19 | ||||
| -rw-r--r-- | app/Models/Themes.php | 12 | ||||
| -rw-r--r-- | app/Models/UserDAO.php | 7 | ||||
| -rw-r--r-- | app/Models/UserQuery.php | 26 |
21 files changed, 855 insertions, 128 deletions
diff --git a/app/Models/Category.php b/app/Models/Category.php index 197faf942..240dbca73 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -30,7 +30,7 @@ class FreshRSS_Category extends Minz_Model { } public function nbFeed() { if ($this->nbFeed < 0) { - $catDAO = new FreshRSS_CategoryDAO(); + $catDAO = FreshRSS_Factory::createCategoryDao(); $this->nbFeed = $catDAO->countFeed($this->id()); } @@ -38,7 +38,7 @@ class FreshRSS_Category extends Minz_Model { } public function nbNotRead() { if ($this->nbNotRead < 0) { - $catDAO = new FreshRSS_CategoryDAO(); + $catDAO = FreshRSS_Factory::createCategoryDao(); $this->nbNotRead = $catDAO->countNotRead($this->id()); } @@ -68,7 +68,7 @@ class FreshRSS_Category extends Minz_Model { $this->id = $value; } public function _name($value) { - $this->name = mb_strcut(trim($value), 0, 255, 'UTF-8'); + $this->name = trim($value); } public function _feeds($values) { if (!is_array($values)) { diff --git a/app/Models/CategoryDAO.php b/app/Models/CategoryDAO.php index cf6b3bae3..ba7eb765e 100644 --- a/app/Models/CategoryDAO.php +++ b/app/Models/CategoryDAO.php @@ -5,11 +5,15 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable const DEFAULTCATEGORYID = 1; public function addCategory($valuesTmp) { - $sql = 'INSERT INTO `' . $this->prefix . 'category`(name) VALUES(?)'; + $sql = 'INSERT INTO `' . $this->prefix . 'category`(name) ' + . 'SELECT * FROM (SELECT TRIM(?)) c2 ' //TRIM() to provide a type hint as text for PostgreSQL + . 'WHERE NOT EXISTS (SELECT 1 FROM `' . $this->prefix . 'tag` WHERE name = TRIM(?))'; //No tag of the same name $stm = $this->bd->prepare($sql); + $valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'); $values = array( - mb_strcut($valuesTmp['name'], 0, 255, 'UTF-8'), + $valuesTmp['name'], + $valuesTmp['name'], ); if ($stm && $stm->execute($values)) { @@ -35,12 +39,15 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable } public function updateCategory($id, $valuesTmp) { - $sql = 'UPDATE `' . $this->prefix . 'category` SET name=? WHERE id=?'; + $sql = 'UPDATE `' . $this->prefix . 'category` SET name=? WHERE id=? ' + . 'AND NOT EXISTS (SELECT 1 FROM `' . $this->prefix . 'tag` WHERE name = ?)'; //No tag of the same name $stm = $this->bd->prepare($sql); + $valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'); $values = array( $valuesTmp['name'], - $id + $id, + $valuesTmp['name'], ); if ($stm && $stm->execute($values)) { @@ -151,7 +158,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable $sql = 'INSERT INTO `' . $this->prefix . 'category`(id, name) VALUES(?, ?)'; if (parent::$sharedDbType === 'pgsql') { //Force call to nextval() - $sql .= " RETURNING nextval('" . $this->prefix . "category_id_seq');"; + $sql .= ' RETURNING nextval(\'"' . $this->prefix . 'category_id_seq"\');'; } $stm = $this->bd->prepare($sql); diff --git a/app/Models/Context.php b/app/Models/Context.php index 2ca8f80b0..60ec6ff77 100644 --- a/app/Models/Context.php +++ b/app/Models/Context.php @@ -8,6 +8,7 @@ class FreshRSS_Context { public static $user_conf = null; public static $system_conf = null; public static $categories = array(); + public static $tags = array(); public static $name = ''; public static $description = ''; @@ -25,6 +26,8 @@ class FreshRSS_Context { 'starred' => false, 'feed' => false, 'category' => false, + 'tag' => false, + 'tags' => false, ); public static $next_get = 'a'; @@ -91,6 +94,14 @@ class FreshRSS_Context { } else { return 'c_' . self::$current_get['category']; } + } elseif (self::$current_get['tag']) { + if ($array) { + return array('t', self::$current_get['tag']); + } else { + return 't_' . self::$current_get['tag']; + } + } elseif (self::$current_get['tags']) { + return 'T'; } } @@ -117,6 +128,10 @@ class FreshRSS_Context { return self::$current_get['feed'] == $id; case 'c': return self::$current_get['category'] == $id; + case 't': + return self::$current_get['tag'] == $id; + case 'T': + return self::$current_get['tags'] || self::$current_get['tag']; default: return false; } @@ -130,6 +145,7 @@ class FreshRSS_Context { * - 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. @@ -140,7 +156,7 @@ class FreshRSS_Context { $nb_unread = 0; if (empty(self::$categories)) { - $catDAO = new FreshRSS_CategoryDAO(); + $catDAO = FreshRSS_Factory::createCategoryDao(); self::$categories = $catDAO->listCategories(); } @@ -166,12 +182,10 @@ class FreshRSS_Context { if ($feed === null) { $feedDAO = FreshRSS_Factory::createFeedDao(); $feed = $feedDAO->searchById($id); - if (!$feed) { throw new FreshRSS_Context_Exception('Invalid feed: ' . $id); } } - self::$current_get['feed'] = $id; self::$current_get['category'] = $feed->category(); self::$name = $feed->name(); @@ -182,19 +196,37 @@ class FreshRSS_Context { // We try to find the corresponding category. self::$current_get['category'] = $id; if (!isset(self::$categories[$id])) { - $catDAO = new FreshRSS_CategoryDAO(); + $catDAO = FreshRSS_Factory::createCategoryDao(); $cat = $catDAO->searchById($id); - if (!$cat) { throw new FreshRSS_Context_Exception('Invalid category: ' . $id); } } else { $cat = self::$categories[$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; + if (!isset(self::$tags[$id])) { + $tagDAO = FreshRSS_Factory::createTagDao(); + $tag = $tagDAO->searchById($id); + if (!$tag) { + throw new FreshRSS_Context_Exception('Invalid tag: ' . $id); + } + } else { + $tag = self::$tags[$id]; + } + self::$name = $tag->name(); + self::$get_unread = $tag->nbUnread(); + break; + case 'T': + self::$current_get['tags'] = true; + self::$name = _t('index.menu.tags'); + self::$get_unread = 0; + break; default: throw new FreshRSS_Context_Exception('Invalid getter: ' . $get); } @@ -211,7 +243,7 @@ class FreshRSS_Context { self::$next_get = $get; if (empty(self::$categories)) { - $catDAO = new FreshRSS_CategoryDAO(); + $catDAO = FreshRSS_Factory::createCategoryDao(); self::$categories = $catDAO->listCategories(); } diff --git a/app/Models/DatabaseDAO.php b/app/Models/DatabaseDAO.php index b8e5577e4..b331eccc3 100644 --- a/app/Models/DatabaseDAO.php +++ b/app/Models/DatabaseDAO.php @@ -4,6 +4,16 @@ * This class is used to test database is well-constructed. */ class FreshRSS_DatabaseDAO extends Minz_ModelPdo { + + //MySQL error codes + const ER_BAD_FIELD_ERROR = '42S22'; + const ER_BAD_TABLE_ERROR = '42S02'; + const ER_TRUNCATED_WRONG_VALUE_FOR_FIELD = '1366'; + + //MySQL InnoDB maximum index length for UTF8MB4 + //https://dev.mysql.com/doc/refman/8.0/en/innodb-restrictions.html + const LENGTH_INDEX_UNICODE = 191; + public function tablesAreCorrect() { $sql = 'SHOW TABLES'; $stm = $this->bd->prepare($sql); @@ -14,6 +24,9 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo { $this->prefix . 'category' => false, $this->prefix . 'feed' => false, $this->prefix . 'entry' => false, + $this->prefix . 'entrytmp' => false, + $this->prefix . 'tag' => false, + $this->prefix . 'entrytag' => false, ); foreach ($res as $value) { $tables[array_pop($value)] = true; @@ -43,7 +56,7 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo { public function categoryIsCorrect() { return $this->checkTable('category', array( - 'id', 'name' + 'id', 'name', )); } @@ -51,14 +64,33 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo { return $this->checkTable('feed', array( 'id', 'url', 'category', 'name', 'website', 'description', 'lastUpdate', 'priority', 'pathEntries', 'httpAuth', 'error', 'keep_history', 'ttl', 'attributes', - 'cache_nbEntries', 'cache_nbUnreads' + 'cache_nbEntries', 'cache_nbUnreads', )); } public function entryIsCorrect() { return $this->checkTable('entry', array( - 'id', 'guid', 'title', 'author', 'content_bin', 'link', 'date', 'is_read', - 'is_favorite', 'id_feed', 'tags' + 'id', 'guid', 'title', 'author', 'content_bin', 'link', 'date', 'lastSeen', 'hash', 'is_read', + 'is_favorite', 'id_feed', 'tags', + )); + } + + public function entrytmpIsCorrect() { + return $this->checkTable('entrytmp', array( + 'id', 'guid', 'title', 'author', 'content_bin', 'link', 'date', 'lastSeen', 'hash', 'is_read', + 'is_favorite', 'id_feed', 'tags', + )); + } + + public function tagIsCorrect() { + return $this->checkTable('tag', array( + 'id', 'name', 'attributes', + )); + } + + public function entrytagIsCorrect() { + return $this->checkTable('entrytag', array( + 'id_tag', 'id_entry', )); } @@ -97,28 +129,39 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo { public function optimize() { $ok = true; - - $sql = 'OPTIMIZE TABLE `' . $this->prefix . 'entry`'; //MySQL - $stm = $this->bd->prepare($sql); - $ok &= $stm != false; - if ($stm) { - $ok &= $stm->execute(); - } - - $sql = 'OPTIMIZE TABLE `' . $this->prefix . 'feed`'; //MySQL - $stm = $this->bd->prepare($sql); - $ok &= $stm != false; - if ($stm) { - $ok &= $stm->execute(); + $tables = array('category', 'feed', 'entry', 'entrytmp', 'tag', 'entrytag'); + + foreach ($tables as $table) { + $sql = 'OPTIMIZE TABLE `' . $this->prefix . $table . '`'; //MySQL + $stm = $this->bd->prepare($sql); + $ok &= $stm != false; + if ($stm) { + $ok &= $stm->execute(); + } } + return $ok; + } - $sql = 'OPTIMIZE TABLE `' . $this->prefix . 'category`'; //MySQL - $stm = $this->bd->prepare($sql); - $ok &= $stm != false; - if ($stm) { - $ok &= $stm->execute(); + public function ensureCaseInsensitiveGuids() { + $ok = true; + $db = FreshRSS_Context::$system_conf->db; + if ($db['type'] === 'mysql') { + include_once(APP_PATH . '/SQL/install.sql.mysql.php'); + if (defined('SQL_UPDATE_GUID_LATIN1_BIN')) { //FreshRSS 1.12 + try { + $sql = sprintf(SQL_UPDATE_GUID_LATIN1_BIN, $this->prefix); + $stm = $this->bd->prepare($sql); + $ok = $stm->execute(); + } catch (Exception $e) { + $ok = false; + Minz_Log::error('FreshRSS_DatabaseDAO::ensureCaseInsensitiveGuids error: ' . $e->getMessage()); + } + } } - return $ok; } + + public function minorDbMaintenance() { + $this->ensureCaseInsensitiveGuids(); + } } diff --git a/app/Models/DatabaseDAOPGSQL.php b/app/Models/DatabaseDAOPGSQL.php index 1b3f7408d..8582b5719 100644 --- a/app/Models/DatabaseDAOPGSQL.php +++ b/app/Models/DatabaseDAOPGSQL.php @@ -3,7 +3,12 @@ /** * This class is used to test database is well-constructed. */ -class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAO { +class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAOSQLite { + + //PostgreSQL error codes + const UNDEFINED_COLUMN = '42703'; + const UNDEFINED_TABLE = '42P01'; + public function tablesAreCorrect() { $db = FreshRSS_Context::$system_conf->db; $dbowner = $db['user']; @@ -17,6 +22,9 @@ class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAO { $this->prefix . 'category' => false, $this->prefix . 'feed' => false, $this->prefix . 'entry' => false, + $this->prefix . 'entrytmp' => false, + $this->prefix . 'tag' => false, + $this->prefix . 'entrytag' => false, ); foreach ($res as $value) { $tables[array_pop($value)] = true; @@ -53,28 +61,16 @@ class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAO { public function optimize() { $ok = true; + $tables = array('category', 'feed', 'entry', 'entrytmp', 'tag', 'entrytag'); - $sql = 'VACUUM `' . $this->prefix . 'entry`'; - $stm = $this->bd->prepare($sql); - $ok &= $stm != false; - if ($stm) { - $ok &= $stm->execute(); - } - - $sql = 'VACUUM `' . $this->prefix . 'feed`'; - $stm = $this->bd->prepare($sql); - $ok &= $stm != false; - if ($stm) { - $ok &= $stm->execute(); + foreach ($tables as $table) { + $sql = 'VACUUM `' . $this->prefix . $table . '`'; + $stm = $this->bd->prepare($sql); + $ok &= $stm != false; + if ($stm) { + $ok &= $stm->execute(); + } } - - $sql = 'VACUUM `' . $this->prefix . 'category`'; - $stm = $this->bd->prepare($sql); - $ok &= $stm != false; - if ($stm) { - $ok &= $stm->execute(); - } - return $ok; } } diff --git a/app/Models/DatabaseDAOSQLite.php b/app/Models/DatabaseDAOSQLite.php index d3aedb3c0..a93a209b2 100644 --- a/app/Models/DatabaseDAOSQLite.php +++ b/app/Models/DatabaseDAOSQLite.php @@ -14,6 +14,9 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO { 'category' => false, 'feed' => false, 'entry' => false, + 'entrytmp' => false, + 'tag' => false, + 'entrytag' => false, ); foreach ($res as $value) { $tables[$value['name']] = true; @@ -32,8 +35,15 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO { public function entryIsCorrect() { return $this->checkTable('entry', array( - 'id', 'guid', 'title', 'author', 'content', 'link', 'date', 'is_read', - 'is_favorite', 'id_feed', 'tags' + 'id', 'guid', 'title', 'author', 'content', 'link', 'date', 'lastSeen', 'hash', 'is_read', + 'is_favorite', 'id_feed', 'tags', + )); + } + + public function entrytmpIsCorrect() { + return $this->checkTable('entrytmp', array( + 'id', 'guid', 'title', 'author', 'content', 'link', 'date', 'lastSeen', 'hash', 'is_read', + 'is_favorite', 'id_feed', 'tags', )); } diff --git a/app/Models/Entry.php b/app/Models/Entry.php index ccbad5724..985276734 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -10,7 +10,7 @@ class FreshRSS_Entry extends Minz_Model { private $id = 0; private $guid; private $title; - private $author; + private $authors; private $content; private $link; private $date; @@ -21,18 +21,17 @@ class FreshRSS_Entry extends Minz_Model { private $feed; private $tags; - public function __construct($feedId = '', $guid = '', $title = '', $author = '', $content = '', + public function __construct($feedId = '', $guid = '', $title = '', $authors = '', $content = '', $link = '', $pubdate = 0, $is_read = false, $is_favorite = false, $tags = '') { $this->_title($title); - $this->_author($author); + $this->_authors($authors); $this->_content($content); $this->_link($link); $this->_date($pubdate); $this->_isRead($is_read); $this->_isFavorite($is_favorite); $this->_feedId($feedId); - $tags = mb_strcut($tags, 0, 1023, 'UTF-8'); - $this->_tags(preg_split('/[\s#]/', $tags)); + $this->_tags($tags); $this->_guid($guid); } @@ -46,7 +45,15 @@ class FreshRSS_Entry extends Minz_Model { return $this->title; } public function author() { - return $this->author === null ? '' : $this->author; + //Deprecated + return $this->authors(true); + } + public function authors($asString = false) { + if ($asString) { + return $this->authors == null ? '' : ';' . implode('; ', $this->authors); + } else { + return $this->authors; + } } public function content() { return $this->content; @@ -86,9 +93,9 @@ class FreshRSS_Entry extends Minz_Model { return $this->feedId; } } - public function tags($inString = false) { - if ($inString) { - return empty($this->tags) ? '' : '#' . implode(' #', $this->tags); + public function tags($asString = false) { + if ($asString) { + return $this->tags == null ? '' : '#' . implode(' #', $this->tags); } else { return $this->tags; } @@ -97,7 +104,7 @@ class FreshRSS_Entry extends Minz_Model { public function hash() { if ($this->hash === null) { //Do not include $this->date because it may be automatically generated when lacking - $this->hash = md5($this->link . $this->title . $this->author . $this->content . $this->tags(true)); + $this->hash = md5($this->link . $this->title . $this->authors(true) . $this->content . $this->tags(true)); } return $this->hash; } @@ -124,11 +131,22 @@ class FreshRSS_Entry extends Minz_Model { } public function _title($value) { $this->hash = null; - $this->title = mb_strcut($value, 0, 255, 'UTF-8'); + $this->title = $value; } public function _author($value) { + //Deprecated + $this->_authors($value); + } + public function _authors($value) { $this->hash = null; - $this->author = mb_strcut($value, 0, 255, 'UTF-8'); + if (!is_array($value)) { + if (strpos($value, ';') !== false) { + $value = preg_split('/\s*[;]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY); + } else { + $value = preg_split('/\s*[,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY); + } + } + $this->authors = $value; } public function _content($value) { $this->hash = null; @@ -162,15 +180,8 @@ class FreshRSS_Entry extends Minz_Model { public function _tags($value) { $this->hash = null; if (!is_array($value)) { - $value = array($value); - } - - foreach ($value as $key => $t) { - if (!$t) { - unset($value[$key]); - } + $value = preg_split('/\s*[#,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY); } - $this->tags = $value; } @@ -287,7 +298,7 @@ class FreshRSS_Entry extends Minz_Model { 'id' => $this->id(), 'guid' => $this->guid(), 'title' => $this->title(), - 'author' => $this->author(), + 'author' => $this->authors(true), 'content' => $this->content(), 'link' => $this->link(), 'date' => $this->date(true), diff --git a/app/Models/EntryDAO.php b/app/Models/EntryDAO.php index f0e164995..a01c2227b 100644 --- a/app/Models/EntryDAO.php +++ b/app/Models/EntryDAO.php @@ -18,6 +18,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable { return 'hex(' . $x . ')'; } + //TODO: Move the database auto-updates to DatabaseDAO protected function addColumn($name) { Minz_Log::warning('FreshRSS_EntryDAO::addColumn: ' . $name); $hasTransaction = false; @@ -56,6 +57,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable { private $triedUpdateToUtf8mb4 = false; + //TODO: Move the database auto-updates to DatabaseDAO protected function updateToUtf8mb4() { if ($this->triedUpdateToUtf8mb4) { return false; @@ -65,7 +67,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable { if ($db['type'] === 'mysql') { include_once(APP_PATH . '/SQL/install.sql.mysql.php'); if (defined('SQL_UPDATE_UTF8MB4')) { - Minz_Log::warning('Updating MySQL to UTF8MB4...'); + Minz_Log::warning('Updating MySQL to UTF8MB4...'); //v1.5.0 $hadTransaction = $this->bd->inTransaction(); if ($hadTransaction) { $this->bd->commit(); @@ -88,6 +90,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable { return false; } + //TODO: Move the database auto-updates to DatabaseDAO protected function createEntryTempTable() { $ok = false; $hadTransaction = $this->bd->inTransaction(); @@ -120,22 +123,28 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable { return $ok; } + //TODO: Move the database auto-updates to DatabaseDAO protected function autoUpdateDb($errorInfo) { if (isset($errorInfo[0])) { - if ($errorInfo[0] === '42S22') { //ER_BAD_FIELD_ERROR + if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR) { //autoAddColumn foreach (array('lastSeen', 'hash') as $column) { if (stripos($errorInfo[2], $column) !== false) { return $this->addColumn($column); } } - } elseif ($errorInfo[0] === '42S02' && stripos($errorInfo[2], 'entrytmp') !== false) { //ER_BAD_TABLE_ERROR - return $this->createEntryTempTable(); //v1.7 + } elseif ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_TABLE_ERROR) { + if (stripos($errorInfo[2], 'tag') !== false) { + $tagDAO = FreshRSS_Factory::createTagDao(); + return $tagDAO->createTagTable(); //v1.12.0 + } elseif (stripos($errorInfo[2], 'entrytmp') !== false) { + return $this->createEntryTempTable(); //v1.7.0 + } } } if (isset($errorInfo[1])) { - if ($errorInfo[1] == '1366') { //ER_TRUNCATED_WRONG_VALUE_FOR_FIELD - return $this->updateToUtf8mb4(); + if ($errorInfo[1] == FreshRSS_DatabaseDAO::ER_TRUNCATED_WRONG_VALUE_FOR_FIELD) { + return $this->updateToUtf8mb4(); //v1.5.0 } } return false; @@ -560,11 +569,52 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable { return $affected; } + /** + * Mark all the articles in a tag as read. + * @param integer $id tag ID, or empty for targetting any tag + * @param integer $idMax max article ID + * @return integer affected rows + */ + public function markReadTag($id = '', $idMax = 0, $filters = null, $state = 0, $is_read = true) { + FreshRSS_UserDAO::touch(); + if ($idMax == 0) { + $idMax = time() . '000000'; + Minz_Log::debug('Calling markReadTag(0) is deprecated!'); + } + + $sql = 'UPDATE `' . $this->prefix . 'entry` e INNER JOIN `' . $this->prefix . 'entrytag` et ON et.id_entry = e.id ' + . 'SET e.is_read = ? ' + . 'WHERE ' + . ($id == '' ? '' : 'et.id_tag = ? AND ') + . 'e.is_read <> ? AND e.id <= ?'; + $values = array($is_read ? 1 : 0); + if ($id != '') { + $values[] = $id; + } + $values[] = $is_read ? 1 : 0; + $values[] = $idMax; + + list($searchValues, $search) = $this->sqlListEntriesWhere('e.', $filters, $state); + + $stm = $this->bd->prepare($sql . $search); + if (!($stm && $stm->execute(array_merge($values, $searchValues)))) { + $info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo(); + Minz_Log::error('SQL error markReadTag: ' . $info[2]); + return false; + } + $affected = $stm->rowCount(); + if (($affected > 0) && (!$this->updateCacheUnreads(false, false))) { + return false; + } + return $affected; + } + public function cleanOldEntries($id_feed, $date_min, $keep = 15) { //Remember to call updateCachedValue($id_feed) or updateCachedValues() just after $sql = 'DELETE FROM `' . $this->prefix . 'entry` ' . 'WHERE id_feed=:id_feed AND id<=:id_max ' . 'AND is_favorite=0 ' //Do not remove favourites . 'AND `lastSeen` < (SELECT maxLastSeen FROM (SELECT (MAX(e3.`lastSeen`)-99) AS maxLastSeen FROM `' . $this->prefix . 'entry` e3 WHERE e3.id_feed=:id_feed) recent) ' //Do not remove the most newly seen articles, plus a few seconds of tolerance + . 'AND id NOT IN (SELECT id_entry FROM `' . $this->prefix . 'entrytag`) ' //Do not purge tagged entries . 'AND id NOT IN (SELECT id FROM (SELECT e2.id FROM `' . $this->prefix . 'entry` e2 WHERE e2.id_feed=:id_feed ORDER BY id DESC LIMIT :keep) keep)'; //Double select: MySQL doesn't support 'LIMIT & IN/ALL/ANY/SOME subquery' $stm = $this->bd->prepare($sql); @@ -770,24 +820,31 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable { $joinFeed = false; $values = array(); switch ($type) { - case 'a': + case 'a': //All PRIORITY_MAIN_STREAM $where .= 'f.priority > ' . FreshRSS_Feed::PRIORITY_NORMAL . ' '; break; - case 's': //Deprecated: use $state instead + case 'A': //All except PRIORITY_ARCHIVED + $where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_NORMAL . ' '; + break; + case 's': //Starred. Deprecated: use $state instead $where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_NORMAL . ' '; $where .= 'AND e.is_favorite=1 '; break; - case 'c': + case 'c': //Category $where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_NORMAL . ' '; $where .= 'AND f.category=? '; $values[] = intval($id); break; - case 'f': + case 'f': //Feed $where .= 'e.id_feed=? '; $values[] = intval($id); break; - case 'A': - $where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_NORMAL . ' '; + case 't': //Tag + $where .= 'et.id_tag=? '; + $values[] = intval($id); + break; + case 'T': //Any tag + $where .= '1=1 '; break; default: throw new FreshRSS_EntriesGetter_Exception('Bad type in Entry->listByType: [' . $type . ']!'); @@ -796,8 +853,11 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable { list($searchValues, $search) = $this->sqlListEntriesWhere('e.', $filters, $state, $order, $firstId, $date_min); return array(array_merge($values, $searchValues), - 'SELECT e.id FROM `' . $this->prefix . 'entry` e ' + 'SELECT ' + . ($type === 'T' ? 'DISTINCT ' : '') + . 'e.id FROM `' . $this->prefix . 'entry` e ' . 'INNER JOIN `' . $this->prefix . 'feed` f ON e.id_feed = f.id ' + . ($type === 't' || $type === 'T' ? 'INNER JOIN `' . $this->prefix . 'entrytag` et ON et.id_entry = e.id ' : '') . 'WHERE ' . $where . $search . 'ORDER BY e.id ' . $order @@ -817,13 +877,22 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable { . 'ORDER BY e0.id ' . $order; $stm = $this->bd->prepare($sql); - $stm->execute($values); - return $stm; + if ($stm && $stm->execute($values)) { + return $stm; + } else { + $info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo(); + Minz_Log::error('SQL error listWhereRaw: ' . $info[2]); + return false; + } } public function listWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filters = null, $date_min = 0) { $stm = $this->listWhereRaw($type, $id, $state, $order, $limit, $firstId, $filters, $date_min); - return self::daoToEntries($stm->fetchAll(PDO::FETCH_ASSOC)); + if ($stm) { + return self::daoToEntries($stm->fetchAll(PDO::FETCH_ASSOC)); + } else { + return false; + } } public function listByIds($ids, $order = 'DESC') { @@ -923,7 +992,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable { $stm = $this->bd->prepare($sql); $stm->execute(); $res = $stm->fetchAll(PDO::FETCH_COLUMN, 0); - return $res[0]; + return isset($res[0]) ? $res[0] : 0; } public function countNotRead($minPriority = null) { $sql = 'SELECT COUNT(e.id) AS count FROM `' . $this->prefix . 'entry` e'; diff --git a/app/Models/EntryDAOPGSQL.php b/app/Models/EntryDAOPGSQL.php index f09fe8e75..aef258b6f 100644 --- a/app/Models/EntryDAOPGSQL.php +++ b/app/Models/EntryDAOPGSQL.php @@ -12,8 +12,13 @@ class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite { protected function autoUpdateDb($errorInfo) { if (isset($errorInfo[0])) { - if ($errorInfo[0] === '42P01' && stripos($errorInfo[2], 'entrytmp') !== false) { //undefined_table - return $this->createEntryTempTable(); + if ($errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_TABLE) { + if (stripos($errorInfo[2], 'tag') !== false) { + $tagDAO = FreshRSS_Factory::createTagDao(); + return $tagDAO->createTagTable(); //v1.12.0 + } elseif (stripos($errorInfo[2], 'entrytmp') !== false) { + return $this->createEntryTempTable(); //v1.7.0 + } } } return false; diff --git a/app/Models/EntryDAOSQLite.php b/app/Models/EntryDAOSQLite.php index 944de8470..f8cd14fe6 100644 --- a/app/Models/EntryDAOSQLite.php +++ b/app/Models/EntryDAOSQLite.php @@ -7,10 +7,17 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO { } protected function autoUpdateDb($errorInfo) { + if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='tag'")) { + $showCreate = $tableInfo->fetchColumn(); + if (stripos($showCreate, 'tag') === false) { + $tagDAO = FreshRSS_Factory::createTagDao(); + return $tagDAO->createTagTable(); //v1.12.0 + } + } if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='entrytmp'")) { $showCreate = $tableInfo->fetchColumn(); if (stripos($showCreate, 'entrytmp') === false) { - return $this->createEntryTempTable(); + return $this->createEntryTempTable(); //v1.7.0 } } if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='entry'")) { @@ -228,4 +235,43 @@ DROP TABLE IF EXISTS `tmp`; } return $affected; } + + /** + * Mark all the articles in a tag as read. + * @param integer $id tag ID, or empty for targetting any tag + * @param integer $idMax max article ID + * @return integer affected rows + */ + public function markReadTag($id = '', $idMax = 0, $filters = null, $state = 0, $is_read = true) { + FreshRSS_UserDAO::touch(); + if ($idMax == 0) { + $idMax = time() . '000000'; + Minz_Log::debug('Calling markReadTag(0) is deprecated!'); + } + + $sql = 'UPDATE `' . $this->prefix . 'entry` e ' + . 'SET e.is_read = ? ' + . 'WHERE e.is_read <> ? AND e.id <= ? AND ' + . 'e.id IN (SELECT et.id_entry FROM `' . $this->prefix . 'entrytag` et ' + . ($id == '' ? '' : 'WHERE et.id = ?') + . ')'; + $values = array($is_read ? 1 : 0, $is_read ? 1 : 0, $idMax); + if ($id != '') { + $values[] = $id; + } + + list($searchValues, $search) = $this->sqlListEntriesWhere('e.', $filters, $state); + + $stm = $this->bd->prepare($sql . $search); + if (!($stm && $stm->execute(array_merge($values, $searchValues)))) { + $info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo(); + Minz_Log::error('SQL error markReadTag: ' . $info[2]); + return false; + } + $affected = $stm->rowCount(); + if (($affected > 0) && (!$this->updateCacheUnreads(false, false))) { + return false; + } + return $affected; + } } diff --git a/app/Models/Factory.php b/app/Models/Factory.php index 764987c46..1accb491c 100644 --- a/app/Models/Factory.php +++ b/app/Models/Factory.php @@ -2,6 +2,10 @@ class FreshRSS_Factory { + public static function createCategoryDao($username = null) { + return new FreshRSS_CategoryDAO($username); + } + public static function createFeedDao($username = null) { $conf = Minz_Configuration::get('system'); switch ($conf->db['type']) { @@ -24,6 +28,18 @@ class FreshRSS_Factory { } } + public static function createTagDao($username = null) { + $conf = Minz_Configuration::get('system'); + switch ($conf->db['type']) { + case 'sqlite': + return new FreshRSS_TagDAOSQLite($username); + case 'pgsql': + return new FreshRSS_TagDAOPGSQL($username); + default: + return new FreshRSS_TagDAO($username); + } + } + public static function createStatsDAO($username = null) { $conf = Minz_Configuration::get('system'); switch ($conf->db['type']) { diff --git a/app/Models/Feed.php b/app/Models/Feed.php index ed381a867..e1dd2990d 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -286,6 +286,10 @@ class FreshRSS_Feed extends Minz_Model { if (!$loadDetails) { //Only activates auto-discovery when adding a new feed $feed->set_autodiscovery_level(SIMPLEPIE_LOCATOR_NONE); } + if ($this->attributes('clear_cache')) { + // Do not use `$simplePie->enable_cache(false);` as it would prevent caching in multiuser context + $this->clearCache(); + } Minz_ExtensionManager::callHook('simplepie_before_init', $feed, $this); $mtime = $feed->init(); @@ -345,13 +349,21 @@ class FreshRSS_Feed extends Minz_Model { $link = $item->get_permalink(); $date = @strtotime($item->get_date()); - // gestion des tags (catégorie == tag) - $tags_tmp = $item->get_categories(); + //Tag processing (tag == category) + $categories = $item->get_categories(); $tags = array(); - if ($tags_tmp !== null) { - foreach ($tags_tmp as $tag) { - $tags[] = html_only_entity_decode($tag->get_label()); + if (is_array($categories)) { + foreach ($categories as $category) { + $text = html_only_entity_decode($category->get_label()); + //Some feeds use a single category with comma-separated tags + $labels = explode(',', $text); + if (is_array($labels)) { + foreach ($labels as $label) { + $tags[] = trim($label); + } + } } + $tags = array_unique($tags); } $content = html_only_entity_decode($item->get_content()); @@ -412,7 +424,7 @@ class FreshRSS_Feed extends Minz_Model { $author_names = ''; if (is_array($authors)) { foreach ($authors as $author) { - $author_names .= html_only_entity_decode(strip_tags($author->name == '' ? $author->email : $author->name)) . ', '; + $author_names .= html_only_entity_decode(strip_tags($author->name == '' ? $author->email : $author->name)) . '; '; } } $author_names = substr($author_names, 0, -2); @@ -457,8 +469,16 @@ class FreshRSS_Feed extends Minz_Model { $this->entries = $entries; } + protected function cacheFilename() { + return CACHE_PATH . '/' . md5($this->url) . '.spc'; + } + + public function clearCache() { + return @unlink($this->cacheFilename()); + } + public function cacheModifiedTime() { - return @filemtime(CACHE_PATH . '/' . md5($this->url) . '.spc'); + return @filemtime($this->cacheFilename()); } public function lock() { diff --git a/app/Models/FeedDAO.php b/app/Models/FeedDAO.php index 285f17193..e579f5881 100644 --- a/app/Models/FeedDAO.php +++ b/app/Models/FeedDAO.php @@ -17,7 +17,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { protected function autoUpdateDb($errorInfo) { if (isset($errorInfo[0])) { - if ($errorInfo[0] === '42S22' || $errorInfo[0] === '42703') { //ER_BAD_FIELD_ERROR (Mysql), undefined_column (PostgreSQL) + if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) { foreach (array('attributes') as $column) { if (stripos($errorInfo[2], $column) !== false) { return $this->addColumn($column); @@ -55,7 +55,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { $values = array( substr($valuesTmp['url'], 0, 511), $valuesTmp['category'], - mb_strcut($valuesTmp['name'], 0, 255, 'UTF-8'), + mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'), substr($valuesTmp['website'], 0, 255), mb_strcut($valuesTmp['description'], 0, 1023, 'UTF-8'), $valuesTmp['lastUpdate'], @@ -109,6 +109,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { } public function updateFeed($id, $valuesTmp) { + if (isset($valuesTmp['name'])) { + $valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'); + } if (isset($valuesTmp['url'])) { $valuesTmp['url'] = safe_ascii($valuesTmp['url']); } @@ -180,7 +183,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { } public function changeCategory($idOldCat, $idNewCat) { - $catDAO = new FreshRSS_CategoryDAO(); + $catDAO = FreshRSS_Factory::createCategoryDao(); $newCat = $catDAO->searchById($idNewCat); if (!$newCat) { $newCat = $catDAO->getDefault(); diff --git a/app/Models/Search.php b/app/Models/Search.php index 5cc7f8e8d..c52e391fa 100644 --- a/app/Models/Search.php +++ b/app/Models/Search.php @@ -40,7 +40,7 @@ class FreshRSS_Search { $input = $this->parseNotIntitleSearch($input); $input = $this->parseNotAuthorSearch($input); $input = $this->parseNotInurlSearch($input); - $input = $this->parseNotTagsSeach($input); + $input = $this->parseNotTagsSearch($input); $input = $this->parsePubdateSearch($input); $input = $this->parseDateSearch($input); @@ -48,7 +48,7 @@ class FreshRSS_Search { $input = $this->parseIntitleSearch($input); $input = $this->parseAuthorSearch($input); $input = $this->parseInurlSearch($input); - $input = $this->parseTagsSeach($input); + $input = $this->parseTagsSearch($input); $input = $this->parseNotSearch($input); $input = $this->parseSearch($input); @@ -117,6 +117,17 @@ class FreshRSS_Search { return is_array($anArray) ? array_filter($anArray, function($value) { return $value !== ''; }) : array(); } + private static function decodeSpaces($value) { + if (is_array($value)) { + for ($i = count($value) - 1; $i >= 0; $i--) { + $value[$i] = self::decodeSpaces($value[$i]); + } + } else { + $value = trim(str_replace('+', ' ', $value)); + } + return $value; + } + /** * Parse the search string to find intitle keyword and the search related * to it. @@ -130,11 +141,12 @@ class FreshRSS_Search { $this->intitle = $matches['search']; $input = str_replace($matches[0], '', $input); } - if (preg_match_all('/\bintitle:(?P<search>\w*)/', $input, $matches)) { + if (preg_match_all('/\bintitle:(?P<search>[^\s"]*)/', $input, $matches)) { $this->intitle = array_merge($this->intitle ? $this->intitle : array(), $matches['search']); $input = str_replace($matches[0], '', $input); } $this->intitle = self::removeEmptyValues($this->intitle); + $this->intitle = self::decodeSpaces($this->intitle); return $input; } @@ -143,11 +155,12 @@ class FreshRSS_Search { $this->not_intitle = $matches['search']; $input = str_replace($matches[0], '', $input); } - if (preg_match_all('/[!-]intitle:(?P<search>\w*)/', $input, $matches)) { + if (preg_match_all('/[!-]intitle:(?P<search>[^\s"]*)/', $input, $matches)) { $this->not_intitle = array_merge($this->not_intitle ? $this->not_intitle : array(), $matches['search']); $input = str_replace($matches[0], '', $input); } $this->not_intitle = self::removeEmptyValues($this->not_intitle); + $this->not_intitle = self::decodeSpaces($this->not_intitle); return $input; } @@ -166,11 +179,12 @@ class FreshRSS_Search { $this->author = $matches['search']; $input = str_replace($matches[0], '', $input); } - if (preg_match_all('/\bauthor:(?P<search>\w*)/', $input, $matches)) { + if (preg_match_all('/\bauthor:(?P<search>[^\s"]*)/', $input, $matches)) { $this->author = array_merge($this->author ? $this->author : array(), $matches['search']); $input = str_replace($matches[0], '', $input); } $this->author = self::removeEmptyValues($this->author); + $this->author = self::decodeSpaces($this->author); return $input; } @@ -179,11 +193,12 @@ class FreshRSS_Search { $this->not_author = $matches['search']; $input = str_replace($matches[0], '', $input); } - if (preg_match_all('/[!-]author:(?P<search>\w*)/', $input, $matches)) { + if (preg_match_all('/[!-]author:(?P<search>[^\s"]*)/', $input, $matches)) { $this->not_author = array_merge($this->not_author ? $this->not_author : array(), $matches['search']); $input = str_replace($matches[0], '', $input); } $this->not_author = self::removeEmptyValues($this->not_author); + $this->not_author = self::decodeSpaces($this->not_author); return $input; } @@ -201,6 +216,7 @@ class FreshRSS_Search { $input = str_replace($matches[0], '', $input); } $this->inurl = self::removeEmptyValues($this->inurl); + $this->inurl = self::decodeSpaces($this->inurl); return $input; } @@ -210,6 +226,7 @@ class FreshRSS_Search { $input = str_replace($matches[0], '', $input); } $this->not_inurl = self::removeEmptyValues($this->not_inurl); + $this->not_inurl = self::decodeSpaces($this->not_inurl); return $input; } @@ -259,21 +276,23 @@ class FreshRSS_Search { * @param string $input * @return string */ - private function parseTagsSeach($input) { + private function parseTagsSearch($input) { if (preg_match_all('/#(?P<search>[^\s]+)/', $input, $matches)) { $this->tags = $matches['search']; $input = str_replace($matches[0], '', $input); } $this->tags = self::removeEmptyValues($this->tags); + $this->tags = self::decodeSpaces($this->tags); return $input; } - private function parseNotTagsSeach($input) { + private function parseNotTagsSearch($input) { if (preg_match_all('/[!-]#(?P<search>[^\s]+)/', $input, $matches)) { $this->not_tags = $matches['search']; $input = str_replace($matches[0], '', $input); } $this->not_tags = self::removeEmptyValues($this->not_tags); + $this->not_tags = self::decodeSpaces($this->not_tags); return $input; } @@ -303,6 +322,7 @@ class FreshRSS_Search { } else { $this->search = explode(' ', $input); } + $this->search = self::decodeSpaces($this->search); } private function parseNotSearch($input) { @@ -322,6 +342,7 @@ class FreshRSS_Search { $input = str_replace($matches[0], '', $input); } $this->not_search = self::removeEmptyValues($this->not_search); + $this->not_search = self::decodeSpaces($this->not_search); return $input; } diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 000000000..3eb989cc1 --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,76 @@ +<?php + +class FreshRSS_Tag extends Minz_Model { + private $id = 0; + private $name; + private $attributes = array(); + private $nbEntries = -1; + private $nbUnread = -1; + + public function __construct($name = '') { + $this->_name($name); + } + + public function id() { + return $this->id; + } + + public function _id($value) { + $this->id = (int)$value; + } + + public function name() { + return $this->name; + } + + public function _name($value) { + $this->name = trim($value); + } + + public function attributes($key = '') { + if ($key == '') { + return $this->attributes; + } else { + return isset($this->attributes[$key]) ? $this->attributes[$key] : null; + } + } + + public function _attributes($key, $value) { + if ($key == '') { + if (is_string($value)) { + $value = json_decode($value, true); + } + if (is_array($value)) { + $this->attributes = $value; + } + } elseif ($value === null) { + unset($this->attributes[$key]); + } else { + $this->attributes[$key] = $value; + } + } + + public function nbEntries() { + if ($this->nbEntries < 0) { + $tagDAO = FreshRSS_Factory::createTagDao(); + $this->nbEntries = $tagDAO->countEntries($this->id()); + } + return $this->nbFeed; + } + + public function _nbEntries($value) { + $this->nbEntries = (int)$value; + } + + public function nbUnread() { + if ($this->nbUnread < 0) { + $tagDAO = FreshRSS_Factory::createTagDao(); + $this->nbUnread = $tagDAO->countNotRead($this->id()); + } + return $this->nbUnread; + } + + public function _nbUnread($value) { + $this->nbUnread = (int)$value; + } +} diff --git a/app/Models/TagDAO.php b/app/Models/TagDAO.php new file mode 100644 index 000000000..1b59c8971 --- /dev/null +++ b/app/Models/TagDAO.php @@ -0,0 +1,315 @@ +<?php + +class FreshRSS_TagDAO extends Minz_ModelPdo implements FreshRSS_Searchable { + + public function sqlIgnore() { + return 'IGNORE'; + } + + public function createTagTable() { + $ok = false; + $hadTransaction = $this->bd->inTransaction(); + if ($hadTransaction) { + $this->bd->commit(); + } + try { + $db = FreshRSS_Context::$system_conf->db; + require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php'); + + Minz_Log::warning('SQL ALTER GUID case sensitivity...'); + $databaseDAO = FreshRSS_Factory::createDatabaseDAO(); + $databaseDAO->ensureCaseInsensitiveGuids(); + + Minz_Log::warning('SQL CREATE TABLE tag...'); + if (defined('SQL_CREATE_TABLE_TAGS')) { + $sql = sprintf(SQL_CREATE_TABLE_TAGS, $this->prefix); + $stm = $this->bd->prepare($sql); + $ok = $stm && $stm->execute(); + } else { + global $SQL_CREATE_TABLE_TAGS; + $ok = !empty($SQL_CREATE_TABLE_TAGS); + foreach ($SQL_CREATE_TABLE_TAGS as $instruction) { + $sql = sprintf($instruction, $this->prefix); + $stm = $this->bd->prepare($sql); + $ok &= $stm && $stm->execute(); + } + } + } catch (Exception $e) { + Minz_Log::error('FreshRSS_EntryDAO::createTagTable error: ' . $e->getMessage()); + } + if ($hadTransaction) { + $this->bd->beginTransaction(); + } + return $ok; + } + + protected function autoUpdateDb($errorInfo) { + if (isset($errorInfo[0])) { + if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_TABLE_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_TABLE) { + if (stripos($errorInfo[2], 'tag') !== false) { + return $this->createTagTable(); //v1.12.0 + } + } + } + return false; + } + + public function addTag($valuesTmp) { + $sql = 'INSERT INTO `' . $this->prefix . 'tag`(name, attributes) ' + . 'SELECT * FROM (SELECT TRIM(?), TRIM(?)) t2 ' //TRIM() to provide a type hint as text for PostgreSQL + . 'WHERE NOT EXISTS (SELECT 1 FROM `' . $this->prefix . 'category` WHERE name = TRIM(?))'; //No category of the same name + $stm = $this->bd->prepare($sql); + + $valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, 63, 'UTF-8'); + $values = array( + $valuesTmp['name'], + isset($valuesTmp['attributes']) ? json_encode($valuesTmp['attributes']) : '', + $valuesTmp['name'], + ); + + if ($stm && $stm->execute($values)) { + return $this->bd->lastInsertId('"' . $this->prefix . 'tag_id_seq"'); + } else { + $info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo(); + Minz_Log::error('SQL error addTag: ' . $info[2]); + return false; + } + } + + public function addTagObject($tag) { + $tag = $this->searchByName($tag->name()); + if (!$tag) { + $values = array( + 'name' => $tag->name(), + 'attributes' => $tag->attributes(), + ); + return $this->addTag($values); + } + return $tag->id(); + } + + public function updateTag($id, $valuesTmp) { + $sql = 'UPDATE `' . $this->prefix . 'tag` SET name=?, attributes=? WHERE id=? ' + . 'AND NOT EXISTS (SELECT 1 FROM `' . $this->prefix . 'category` WHERE name = ?)'; //No category of the same name + $stm = $this->bd->prepare($sql); + + $valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, 63, 'UTF-8'); + $values = array( + $valuesTmp['name'], + isset($valuesTmp['attributes']) ? json_encode($valuesTmp['attributes']) : '', + $id, + $valuesTmp['name'], + ); + + if ($stm && $stm->execute($values)) { + return $stm->rowCount(); + } else { + $info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo(); + Minz_Log::error('SQL error updateTag: ' . $info[2]); + return false; + } + } + + public function updateTagAttribute($tag, $key, $value) { + if ($tag instanceof FreshRSS_Tag) { + $tag->_attributes($key, $value); + return $this->updateFeed( + $tag->id(), + array('attributes' => $feed->attributes()) + ); + } + return false; + } + + public function deleteTag($id) { + if ($id <= 0) { + return false; + } + $sql = 'DELETE FROM `' . $this->prefix . 'tag` WHERE id=?'; + $stm = $this->bd->prepare($sql); + + $values = array($id); + + if ($stm && $stm->execute($values)) { + return $stm->rowCount(); + } else { + $info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo(); + Minz_Log::error('SQL error deleteTag: ' . $info[2]); + return false; + } + } + + public function searchById($id) { + $sql = 'SELECT * FROM `' . $this->prefix . 'tag` WHERE id=?'; + $stm = $this->bd->prepare($sql); + $values = array($id); + $stm->execute($values); + $res = $stm->fetchAll(PDO::FETCH_ASSOC); + $tag = self::daoToTag($res); + return isset($tag[0]) ? $tag[0] : null; + } + + public function searchByName($name) { + $sql = 'SELECT * FROM `' . $this->prefix . 'tag` WHERE name=?'; + $stm = $this->bd->prepare($sql); + $values = array($name); + $stm->execute($values); + $res = $stm->fetchAll(PDO::FETCH_ASSOC); + $tag = self::daoToTag($res); + return isset($tag[0]) ? $tag[0] : null; + } + + public function listTags($precounts = false) { + if ($precounts) { + $sql = 'SELECT t.id, t.name, count(e.id) AS unreads ' + . 'FROM `' . $this->prefix . 'tag` t ' + . 'LEFT OUTER JOIN `' . $this->prefix . 'entrytag` et ON et.id_tag = t.id ' + . 'LEFT OUTER JOIN `' . $this->prefix . 'entry` e ON et.id_entry = e.id AND e.is_read = 0 ' + . 'GROUP BY t.id ' + . 'ORDER BY t.name'; + } else { + $sql = 'SELECT * FROM `' . $this->prefix . 'tag` ORDER BY name'; + } + + $stm = $this->bd->prepare($sql); + if ($stm && $stm->execute()) { + return self::daoToTag($stm->fetchAll(PDO::FETCH_ASSOC)); + } else { + $info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo(); + if ($this->autoUpdateDb($info)) { + return $this->listTags($precounts); + } + Minz_Log::error('SQL error listTags: ' . $info[2]); + return false; + } + } + + public function count() { + $sql = 'SELECT COUNT(*) AS count FROM `' . $this->prefix . 'tag`'; + $stm = $this->bd->prepare($sql); + $stm->execute(); + $res = $stm->fetchAll(PDO::FETCH_ASSOC); + return $res[0]['count']; + } + + public function countEntries($id) { + $sql = 'SELECT COUNT(*) AS count FROM `' . $this->prefix . 'entrytag` WHERE id_tag=?'; + $stm = $this->bd->prepare($sql); + $values = array($id); + $stm->execute($values); + $res = $stm->fetchAll(PDO::FETCH_ASSOC); + return $res[0]['count']; + } + + public function countNotRead($id) { + $sql = 'SELECT COUNT(*) AS count FROM `' . $this->prefix . 'entrytag` et ' + . 'INNER JOIN `' . $this->prefix . 'entry` e ON et.id_entry=e.id ' + . 'WHERE et.id_tag=? AND e.is_read=0'; + $stm = $this->bd->prepare($sql); + $values = array($id); + $stm->execute($values); + $res = $stm->fetchAll(PDO::FETCH_ASSOC); + return $res[0]['count']; + } + + public function tagEntry($id_tag, $id_entry, $checked = true) { + if ($checked) { + $sql = 'INSERT ' . $this->sqlIgnore() . ' INTO `' . $this->prefix . 'entrytag`(id_tag, id_entry) VALUES(?, ?)'; + } else { + $sql = 'DELETE FROM `' . $this->prefix . 'entrytag` WHERE id_tag=? AND id_entry=?'; + } + $stm = $this->bd->prepare($sql); + $values = array($id_tag, $id_entry); + + if ($stm && $stm->execute($values)) { + return true; + } else { + $info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo(); + Minz_Log::error('SQL error tagEntry: ' . $info[2]); + return false; + } + } + + public function getTagsForEntry($id_entry) { + $sql = 'SELECT t.id, t.name, et.id_entry IS NOT NULL as checked ' + . 'FROM `' . $this->prefix . 'tag` t ' + . 'LEFT OUTER JOIN `' . $this->prefix . 'entrytag` et ON et.id_tag = t.id AND et.id_entry=? ' + . 'ORDER BY t.name'; + + $stm = $this->bd->prepare($sql); + $values = array($id_entry); + + if ($stm && $stm->execute($values)) { + $lines = $stm->fetchAll(PDO::FETCH_ASSOC); + for ($i = count($lines) - 1; $i >= 0; $i--) { + $lines[$i]['id'] = intval($lines[$i]['id']); + $lines[$i]['checked'] = !empty($lines[$i]['checked']); + } + return $lines; + } else { + $info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo(); + if ($this->autoUpdateDb($info)) { + return $this->getTagsForEntry($id_entry); + } + Minz_Log::error('SQL error getTagsForEntry: ' . $info[2]); + return false; + } + } + + //For API + public function getEntryIdsTagNames($entries) { + $sql = 'SELECT et.id_entry, t.name ' + . 'FROM `' . $this->prefix . 'tag` t ' + . 'INNER JOIN `' . $this->prefix . 'entrytag` et ON et.id_tag = t.id'; + + $values = array(); + if (is_array($entries) && count($entries) > 0) { + $sql .= ' AND et.id_entry IN (' . str_repeat('?,', count($entries) - 1). '?)'; + foreach ($entries as $entry) { + $values[] = $entry->id(); + } + } + $stm = $this->bd->prepare($sql); + + if ($stm && $stm->execute($values)) { + $result = array(); + foreach ($stm->fetchAll(PDO::FETCH_ASSOC) as $line) { + $entryId = 'e_' . $line['id_entry']; + $tagName = $line['name']; + if (empty($result[$entryId])) { + $result[$entryId] = array(); + } + $result[$entryId][] = $tagName; + } + return $result; + } else { + $info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo(); + if ($this->autoUpdateDb($info)) { + return $this->getTagNamesEntryIds($id_entry); + } + Minz_Log::error('SQL error getTagNamesEntryIds: ' . $info[2]); + return false; + } + } + + public static function daoToTag($listDAO) { + $list = array(); + if (!is_array($listDAO)) { + $listDAO = array($listDAO); + } + foreach ($listDAO as $key => $dao) { + $tag = new FreshRSS_Tag( + $dao['name'] + ); + $tag->_id($dao['id']); + if (!empty($dao['attributes'])) { + $tag->_attributes('', $dao['attributes']); + } + if (isset($dao['unreads'])) { + $tag->_nbUnread($dao['unreads']); + } + $list[$key] = $tag; + } + return $list; + } +} diff --git a/app/Models/TagDAOPGSQL.php b/app/Models/TagDAOPGSQL.php new file mode 100644 index 000000000..56a28e294 --- /dev/null +++ b/app/Models/TagDAOPGSQL.php @@ -0,0 +1,9 @@ +<?php + +class FreshRSS_TagDAOPGSQL extends FreshRSS_TagDAO { + + public function sqlIgnore() { + return ''; //TODO + } + +} diff --git a/app/Models/TagDAOSQLite.php b/app/Models/TagDAOSQLite.php new file mode 100644 index 000000000..b1deb6c65 --- /dev/null +++ b/app/Models/TagDAOSQLite.php @@ -0,0 +1,19 @@ +<?php + +class FreshRSS_TagDAOSQLite extends FreshRSS_TagDAO { + + public function sqlIgnore() { + return 'OR IGNORE'; + } + + protected function autoUpdateDb($errorInfo) { + if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='tag'")) { + $showCreate = $tableInfo->fetchColumn(); + if (stripos($showCreate, 'tag') === false) { + return $this->createTagTable(); //v1.12.0 + } + } + return false; + } + +} diff --git a/app/Models/Themes.php b/app/Models/Themes.php index 8920fbf7e..235269e39 100644 --- a/app/Models/Themes.php +++ b/app/Models/Themes.php @@ -68,7 +68,7 @@ class FreshRSS_Themes extends Minz_Model { return $infos; } - public static function icon($name, $urlOnly = false) { + public static function alt($name) { static $alts = array( 'add' => '✚', 'all' => '☰', @@ -84,6 +84,7 @@ class FreshRSS_Themes extends Minz_Model { 'icon' => '⊚', 'import' => '⤓', 'key' => '⚿', + 'label' => '🏷️', 'link' => '↗', 'login' => '🔒', 'logout' => '🔓', @@ -104,13 +105,18 @@ class FreshRSS_Themes extends Minz_Model { 'view-global' => '☷', 'view-reader' => '☕', ); - if (!isset($alts[$name])) { + return isset($name) ? $alts[$name] : ''; + } + + public static function icon($name, $urlOnly = false, $altOnly = false) { + $alt = self::alt($name); + if ($alt == '') { return ''; } $url = $name . '.svg'; $url = isset(self::$themeIcons[$url]) ? (self::$themeIconsUrl . $url) : (self::$defaultIconsUrl . $url); - return $urlOnly ? Minz_Url::display($url) : '<img class="icon" src="' . Minz_Url::display($url) . '" alt="' . $alts[$name] . '" />'; + return $urlOnly ? Minz_Url::display($url) : '<img class="icon" src="' . Minz_Url::display($url) . '" alt="' . $alt . '" />'; } } diff --git a/app/Models/UserDAO.php b/app/Models/UserDAO.php index c921d54c9..5fb46c947 100644 --- a/app/Models/UserDAO.php +++ b/app/Models/UserDAO.php @@ -14,14 +14,13 @@ class FreshRSS_UserDAO extends Minz_ModelPdo { $ok = false; $bd_prefix_user = $db['prefix'] . $username . '_'; if (defined('SQL_CREATE_TABLES')) { //E.g. MySQL - $sql = sprintf(SQL_CREATE_TABLES . SQL_CREATE_TABLE_ENTRYTMP, $bd_prefix_user, _t('gen.short.default_category')); + $sql = sprintf(SQL_CREATE_TABLES . SQL_CREATE_TABLE_ENTRYTMP . SQL_CREATE_TABLE_TAGS, $bd_prefix_user, _t('gen.short.default_category')); $stm = $userPDO->bd->prepare($sql); $ok = $stm && $stm->execute(); } else { //E.g. SQLite - global $SQL_CREATE_TABLES; - global $SQL_CREATE_TABLE_ENTRYTMP; + global $SQL_CREATE_TABLES, $SQL_CREATE_TABLE_ENTRYTMP, $SQL_CREATE_TABLE_TAGS; if (is_array($SQL_CREATE_TABLES)) { - $instructions = array_merge($SQL_CREATE_TABLES, $SQL_CREATE_TABLE_ENTRYTMP); + $instructions = array_merge($SQL_CREATE_TABLES, $SQL_CREATE_TABLE_ENTRYTMP, $SQL_CREATE_TABLE_TAGS); $ok = !empty($instructions); foreach ($instructions as $instruction) { $sql = sprintf($instruction, $bd_prefix_user, _t('gen.short.default_category')); diff --git a/app/Models/UserQuery.php b/app/Models/UserQuery.php index ef94fdaf6..f607084f8 100644 --- a/app/Models/UserQuery.php +++ b/app/Models/UserQuery.php @@ -19,15 +19,17 @@ class FreshRSS_UserQuery { private $url; private $feed_dao; private $category_dao; + private $tag_dao; /** * @param array $query * @param FreshRSS_Searchable $feed_dao * @param FreshRSS_Searchable $category_dao */ - public function __construct($query, FreshRSS_Searchable $feed_dao = null, FreshRSS_Searchable $category_dao = null) { + public function __construct($query, FreshRSS_Searchable $feed_dao = null, FreshRSS_Searchable $category_dao = null, FreshRSS_Searchable $tag_dao = null) { $this->category_dao = $category_dao; $this->feed_dao = $feed_dao; + $this->tag_dao = $tag_dao; if (isset($query['get'])) { $this->parseGet($query['get']); } @@ -88,6 +90,9 @@ class FreshRSS_UserQuery { case 's': $this->parseFavorite(); break; + case 't': + $this->parseTag($matches['id']); + break; } } } @@ -139,6 +144,25 @@ class FreshRSS_UserQuery { } /** + * Parse the query string when it is a "tag" query + * + * @param integer $id + * @throws FreshRSS_DAO_Exception + */ + private function parseTag($id) { + if ($this->tag_dao == null) { + throw new FreshRSS_DAO_Exception('Tag DAO is not loaded in UserQuery'); + } + $category = $this->category_dao->searchById($id); + if ($tag) { + $this->get_name = $tag->name(); + } else { + $this->deprecated = true; + } + $this->get_type = 'tag'; + } + + /** * Parse the query string when it is a "favorite" query */ private function parseFavorite() { |
