Przeglądaj źródła

Add database field attributes for entries (#4444)

* Add database field attributes for entries
Just like we already have for categories, feeds, etc.
No core use yet, but allows in particular extensions to save per-entry data

* Fix PHPStand

* Fix wrong variable
Alexandre Alapetite 3 lat temu
rodzic
commit
0866fdaee8

+ 2 - 0
app/Controllers/feedController.php

@@ -426,6 +426,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 								//', old hash ' . $existingHash . ', new hash ' . $entry->hash());
 							$entry->_isRead($mark_updated_article_unread ? false : null);	//Change is_read according to policy.
 
+							/** @var FreshRSS_Entry|null */
 							$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
 							if ($entry === null) {
 								// An extension has returned a null value, there is nothing to insert.
@@ -454,6 +455,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 							$titlesAsRead[$entry->title()] = true;
 						}
 
+						/** @var FreshRSS_Entry|null */
 						$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
 						if ($entry === null) {
 							// An extension has returned a null value, there is nothing to insert.

+ 1 - 0
app/Controllers/importExportController.php

@@ -440,6 +440,7 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
 			}
 			$newGuids[$entry->guid()] = true;
 
+			/** @var FreshRSS_Entry|null */
 			$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
 			if ($entry == null) {
 				// An extension has returned a null value, there is nothing to insert.

+ 28 - 0
app/Models/Entry.php

@@ -44,6 +44,7 @@ class FreshRSS_Entry extends Minz_Model {
 	private $feed;
 
 	private $tags;
+	private $attributes = [];
 
 	public function __construct(int $feedId = 0, string $guid = '', string $title = '', string $authors = '', string $content = '',
 			string $link = '', $pubdate = 0, bool $is_read = false, bool $is_favorite = false, string $tags = '') {
@@ -88,6 +89,9 @@ class FreshRSS_Entry extends Minz_Model {
 		if (!empty($dao['categories'])) {
 			$entry->_tags($dao['categories']);
 		}
+		if (!empty($dao['attributes'])) {
+			$entry->_attributes('', $dao['attributes']);
+		}
 		return $entry;
 	}
 
@@ -228,6 +232,29 @@ class FreshRSS_Entry extends Minz_Model {
 		}
 	}
 
+	public function attributes($key = '') {
+		if ($key == '') {
+			return $this->attributes;
+		} else {
+			return isset($this->attributes[$key]) ? $this->attributes[$key] : null;
+		}
+	}
+
+	public function _attributes(string $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 hash(): string {
 		if ($this->hash == '') {
 			//Do not include $this->date because it may be automatically generated when lacking
@@ -588,6 +615,7 @@ class FreshRSS_Entry extends Minz_Model {
 			'is_favorite' => $this->isFavorite(),
 			'id_feed' => $this->feed(),
 			'tags' => $this->tags(true),
+			'attributes' => $this->attributes(),
 		);
 	}
 }

+ 58 - 12
app/Models/EntryDAO.php

@@ -65,9 +65,33 @@ SQL;
 		return $ok;
 	}
 
+	protected function addColumn(string $name) {
+		Minz_Log::warning(__method__ . ': ' . $name);
+		try {
+			if ($name === 'attributes') {	//v1.20.0
+				$sql = <<<'SQL'
+ALTER TABLE `_entry` ADD COLUMN attributes TEXT;
+ALTER TABLE `_entrytmp` ADD COLUMN attributes TEXT;
+SQL;
+				return $this->pdo->exec($sql) !== false;
+			}
+		} catch (Exception $e) {
+			Minz_Log::error(__method__ . ' error: ' . $e->getMessage());
+		}
+		return false;
+	}
+
 	//TODO: Move the database auto-updates to DatabaseDAO
 	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'] as $column) {
+					if (stripos($errorLines[0], $column) !== false) {
+						return $this->addColumn($column);
+					}
+				}
+			}
 			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_TABLE_ERROR) {
 				if (stripos($errorInfo[2], 'tag') !== false) {
 					$tagDAO = FreshRSS_Factory::createTagDao();
@@ -97,12 +121,12 @@ SQL;
 			$sql = static::sqlIgnoreConflict(
 				'INSERT INTO `_' . ($useTmpTable ? 'entrytmp' : 'entry') . '` (id, guid, title, author, '
 				. (static::isCompressed() ? 'content_bin' : 'content')
-				. ', link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags) '
+				. ', link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes) '
 				. 'VALUES(:id, :guid, :title, :author, '
 				. (static::isCompressed() ? 'COMPRESS(:content)' : ':content')
 				. ', :link, :date, :last_seen, '
 				. static::sqlHexDecode(':hash')
-				. ', :is_read, :is_favorite, :id_feed, :tags)');
+				. ', :is_read, :is_favorite, :id_feed, :tags, :attributes)');
 			$this->addEntryPrepared = $this->pdo->prepare($sql);
 		}
 		if ($this->addEntryPrepared) {
@@ -135,6 +159,11 @@ SQL;
 			$valuesTmp['tags'] = mb_strcut($valuesTmp['tags'], 0, 1023, 'UTF-8');
 			$valuesTmp['tags'] = safe_utf8($valuesTmp['tags']);
 			$this->addEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
+			if (!isset($valuesTmp['attributes'])) {
+				$valuesTmp['attributes'] = [];
+			}
+			$this->addEntryPrepared->bindValue(':attributes', is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] :
+				json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES));
 
 			if (static::hasNativeHex()) {
 				$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
@@ -164,9 +193,9 @@ SET @rank=(SELECT MAX(id) - COUNT(*) FROM `_entrytmp`);
 
 INSERT IGNORE INTO `_entry` (
 	id, guid, title, author, content_bin, link, date, `lastSeen`,
-	hash, is_read, is_favorite, id_feed, tags
+	hash, is_read, is_favorite, id_feed, tags, attributes
 )
-SELECT @rank:=@rank+1 AS id, guid, title, author, content_bin, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags
+SELECT @rank:=@rank+1 AS id, guid, title, author, content_bin, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
 FROM `_entrytmp`
 ORDER BY date, id;
 
@@ -197,7 +226,7 @@ SQL;
 				. ', link=:link, date=:date, `lastSeen`=:last_seen'
 				. ', hash=' . static::sqlHexDecode(':hash')
 				. ', is_read=COALESCE(:is_read, is_read)'
-				. ', tags=:tags '
+				. ', tags=:tags, attributes=:attributes '
 				. 'WHERE id_feed=:id_feed AND guid=:guid';
 			$this->updateEntryPrepared = $this->pdo->prepare($sql);
 		}
@@ -229,6 +258,11 @@ SQL;
 			$valuesTmp['tags'] = mb_strcut($valuesTmp['tags'], 0, 1023, 'UTF-8');
 			$valuesTmp['tags'] = safe_utf8($valuesTmp['tags']);
 			$this->updateEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
+			if (!isset($valuesTmp['attributes'])) {
+				$valuesTmp['attributes'] = [];
+			}
+			$this->updateEntryPrepared->bindValue(':attributes', is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] :
+				json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES));
 
 			if (static::hasNativeHex()) {
 				$this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
@@ -654,11 +688,20 @@ SQL;
 	public function selectAll() {
 		$sql = 'SELECT id, guid, title, author, '
 			. (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-			. ', link, date, `lastSeen`, ' . static::sqlHexEncode('hash') . ' AS hash, is_read, is_favorite, id_feed, tags '
+			. ', link, date, `lastSeen`, ' . static::sqlHexEncode('hash') . ' AS hash, is_read, is_favorite, id_feed, tags, attributes '
 			. 'FROM `_entry`';
 		$stm = $this->pdo->query($sql);
-		while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
-			yield $row;
+		if ($stm != false) {
+			while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
+				yield $row;
+			}
+		} else {
+			$info = $this->pdo->errorInfo();
+			if ($this->autoUpdateDb($info)) {
+				yield from $this->selectAll();
+			}
+			Minz_Log::error(__method__ . ' error: ' . json_encode($info));
+			yield false;
 		}
 	}
 
@@ -667,7 +710,7 @@ SQL;
 		// un guid est unique pour un flux donné
 		$sql = 'SELECT id, guid, title, author, '
 			. (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-			. ', link, date, is_read, is_favorite, id_feed, tags '
+			. ', link, date, is_read, is_favorite, id_feed, tags, attributes '
 			. 'FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid';
 		$stm = $this->pdo->prepare($sql);
 		$stm->bindParam(':id_feed', $id_feed, PDO::PARAM_INT);
@@ -681,7 +724,7 @@ SQL;
 	public function searchById($id) {
 		$sql = 'SELECT id, guid, title, author, '
 			. (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-			. ', link, date, is_read, is_favorite, id_feed, tags '
+			. ', link, date, is_read, is_favorite, id_feed, tags, attributes '
 			. 'FROM `_entry` WHERE id=:id';
 		$stm = $this->pdo->prepare($sql);
 		$stm->bindParam(':id', $id, PDO::PARAM_INT);
@@ -1065,7 +1108,7 @@ SQL;
 
 		$sql = 'SELECT e0.id, e0.guid, e0.title, e0.author, '
 			. (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-			. ', e0.link, e0.date, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags '
+			. ', e0.link, e0.date, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags, e0.attributes '
 			. 'FROM `_entry` e0 '
 			. 'INNER JOIN ('
 			. $sql
@@ -1077,6 +1120,9 @@ SQL;
 			return $stm;
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
+			if ($this->autoUpdateDb($info)) {
+				return $this->listWhereRaw($type, $id, $state, $order, $limit, $firstId, $filters, $date_min);
+			}
 			Minz_Log::error('SQL error listWhereRaw: ' . $info[2]);
 			return false;
 		}
@@ -1110,7 +1156,7 @@ SQL;
 
 		$sql = 'SELECT id, guid, title, author, '
 			. (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-			. ', link, date, is_read, is_favorite, id_feed, tags '
+			. ', link, date, is_read, is_favorite, id_feed, tags, attributes '
 			. 'FROM `_entry` '
 			. 'WHERE id IN (' . str_repeat('?,', count($ids) - 1). '?) '
 			. 'ORDER BY id ' . $order;

+ 10 - 6
app/Models/EntryDAOPGSQL.php

@@ -20,6 +20,14 @@ class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
 
 	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'] as $column) {
+					if (stripos($errorLines[0], $column) !== false) {
+						return $this->addColumn($column);
+					}
+				}
+			}
 			if ($errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_TABLE) {
 				if (stripos($errorInfo[2], 'tag') !== false) {
 					$tagDAO = FreshRSS_Factory::createTagDao();
@@ -32,10 +40,6 @@ class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
 		return false;
 	}
 
-	protected function addColumn(string $name) {
-		return false;
-	}
-
 	public function commitNewEntries() {
 		//TODO: Update to PostgreSQL 9.5+ syntax with ON CONFLICT DO NOTHING
 		$sql = 'DO $$
@@ -44,9 +48,9 @@ maxrank bigint := (SELECT MAX(id) FROM `_entrytmp`);
 rank bigint := (SELECT maxrank - COUNT(*) FROM `_entrytmp`);
 BEGIN
 	INSERT INTO `_entry`
-		(id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags)
+		(id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes)
 		(SELECT rank + row_number() OVER(ORDER BY date, id) AS id, guid, title, author, content,
-			link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags
+			link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
 			FROM `_entrytmp` AS etmp
 			WHERE NOT EXISTS (
 				SELECT 1 FROM `_entry` AS ereal

+ 11 - 3
app/Models/EntryDAOSQLite.php

@@ -23,6 +23,14 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 	}
 
 	protected function autoUpdateDb(array $errorInfo) {
+		if ($tableInfo = $this->pdo->query("PRAGMA table_info('entry')")) {
+			$columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1);
+			foreach (['attributes'] as $column) {
+				if (!in_array($column, $columns)) {
+					return $this->addColumn($column);
+				}
+			}
+		}
 		if ($tableInfo = $this->pdo->query("SELECT sql FROM sqlite_master where name='tag'")) {
 			$showCreate = $tableInfo->fetchColumn();
 			if (stripos($showCreate, 'tag') === false) {
@@ -43,13 +51,13 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 		$sql = '
 DROP TABLE IF EXISTS `tmp`;
 CREATE TEMP TABLE `tmp` AS
-	SELECT id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags
+	SELECT id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
 	FROM `_entrytmp`
 	ORDER BY date, id;
 INSERT OR IGNORE INTO `_entry`
-	(id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags)
+	(id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes)
 	SELECT rowid + (SELECT MAX(id) - COUNT(*) FROM `tmp`) AS id,
-	guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags
+	guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
 	FROM `tmp`
 	ORDER BY date, id;
 DELETE FROM `_entrytmp` WHERE id <= (SELECT MAX(id) FROM `tmp`);

+ 2 - 0
app/SQL/install.sql.mysql.php

@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS `_entry` (
 	`is_favorite` BOOLEAN NOT NULL DEFAULT 0,
 	`id_feed` INT,	-- 1.20.0
 	`tags` VARCHAR(1023),
+	`attributes` TEXT,	-- v1.20.0
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `_feed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE KEY (`id_feed`,`guid`),	-- v0.7
@@ -87,6 +88,7 @@ CREATE TABLE IF NOT EXISTS `_entrytmp` (	-- v1.7
 	`is_favorite` BOOLEAN NOT NULL DEFAULT 0,
 	`id_feed` INT,	-- 1.20.0
 	`tags` VARCHAR(1023),
+	`attributes` TEXT,	-- v1.20.0
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `_feed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE KEY (`id_feed`,`guid`),

+ 2 - 0
app/SQL/install.sql.pgsql.php

@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS `_entry` (
 	"is_favorite" SMALLINT NOT NULL DEFAULT 0,
 	"id_feed" INT,	-- 1.20.0
 	"tags" VARCHAR(1023),
+	"attributes" TEXT,	-- v1.20.0
 	FOREIGN KEY ("id_feed") REFERENCES `_feed` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE ("id_feed","guid")
 );
@@ -82,6 +83,7 @@ CREATE TABLE IF NOT EXISTS `_entrytmp` (	-- v1.7
 	"is_favorite" SMALLINT NOT NULL DEFAULT 0,
 	"id_feed" INT,	-- 1.20.0
 	"tags" VARCHAR(1023),
+	"attributes" TEXT,	-- v1.20.0
 	FOREIGN KEY ("id_feed") REFERENCES `_feed` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE ("id_feed","guid")
 );

+ 2 - 0
app/SQL/install.sql.sqlite.php

@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS `entry` (
 	`is_favorite` BOOLEAN NOT NULL DEFAULT 0,
 	`id_feed` INTEGER,	-- 1.20.0
 	`tags` VARCHAR(1023),
+	`attributes` TEXT,	-- v1.20.0
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `feed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE (`id_feed`,`guid`)
@@ -82,6 +83,7 @@ CREATE TABLE IF NOT EXISTS `entrytmp` (	-- v1.7
 	`is_favorite` BOOLEAN NOT NULL DEFAULT 0,
 	`id_feed` INTEGER,	-- 1.20.0
 	`tags` VARCHAR(1023),
+	`attributes` TEXT,	-- v1.20.0
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `feed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE (`id_feed`,`guid`)