Browse Source

PHPStan Level 6 FreshRSS_Search FreshRSS_Entry (#5292)

* PHPStan Level 6 FreshRSS_Search FreshRSS_Entry

* Minor fix

* Type fix

* Apply suggestions from code review

Co-authored-by: Luc SANCHEZ <4697568+ColonelMoutarde@users.noreply.github.com>

* Minor types syntax
Compatibility Intelephense

---------

Co-authored-by: Luc SANCHEZ <4697568+ColonelMoutarde@users.noreply.github.com>
Alexandre Alapetite 3 years ago
parent
commit
b3121709d6

+ 5 - 2
app/Models/BooleanSearch.php

@@ -10,7 +10,10 @@ class FreshRSS_BooleanSearch {
 	/** @var array<FreshRSS_BooleanSearch|FreshRSS_Search> */
 	private $searches = array();
 
-	/** @var 'AND'|'OR'|'AND NOT' */
+	/**
+	 * @phpstan-var 'AND'|'OR'|'AND NOT'
+	 * @var string
+	 */
 	private $operator;
 
 	/** @param 'AND'|'OR'|'AND NOT' $operator */
@@ -255,7 +258,7 @@ class FreshRSS_BooleanSearch {
 	 * or a series of FreshRSS_Search combined by explicit OR
 	 * @return array<FreshRSS_BooleanSearch|FreshRSS_Search>
 	 */
-	public function searches() {
+	public function searches(): array {
 		return $this->searches;
 	}
 

+ 96 - 76
app/Models/Entry.php

@@ -7,18 +7,13 @@ class FreshRSS_Entry extends Minz_Model {
 	const STATE_FAVORITE = 4;
 	const STATE_NOT_FAVORITE = 8;
 
-	/**
-	 * @var string
-	 */
+	/** @var string */
 	private $id = '0';
-
-	/**
-	 * @var string
-	 */
+	/** @var string */
 	private $guid;
-
 	/** @var string */
 	private $title;
+	/** @var array<string> */
 	private $authors;
 	/** @var string */
 	private $content;
@@ -26,32 +21,26 @@ class FreshRSS_Entry extends Minz_Model {
 	private $link;
 	/** @var int */
 	private $date;
-	private $date_added = 0; //In microseconds
-	/**
-	 * @var string
-	 */
+	/** @var string In microseconds */
+	private $date_added = '0';
+	/** @var string */
 	private $hash = '';
-	/**
-	 * @var bool|null
-	 */
+	/** @var bool|null */
 	private $is_read;
 	/** @var bool|null */
 	private $is_favorite;
-
-	/**
-	 * @var int
-	 */
+	/** @var int */
 	private $feedId;
-
-	/**
-	 * @var FreshRSS_Feed|null
-	 */
+	/** @var FreshRSS_Feed|null */
 	private $feed;
-
 	/** @var array<string> */
 	private $tags = [];
+	/** @var array<string,mixed> */
 	private $attributes = [];
 
+	/**
+	 * @param int|string $pubdate
+	 */
 	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 = '') {
 		$this->_title($title);
@@ -112,10 +101,14 @@ class FreshRSS_Entry extends Minz_Model {
 	public function title(): string {
 		return $this->title == '' ? $this->guid() : $this->title;
 	}
+	/** @deprecated */
 	public function author(): string {
-		//Deprecated
 		return $this->authors(true);
 	}
+	/**
+	 * @phpstan return ($asString ? string : array<string>)
+	 * @return string|array<string>
+	 */
 	public function authors(bool $asString = false) {
 		if ($asString) {
 			return $this->authors == null ? '' : ';' . implode('; ', $this->authors);
@@ -131,6 +124,7 @@ class FreshRSS_Entry extends Minz_Model {
 		return preg_match('/(?P<delim>[\'"])' . preg_quote($link, '/') . '(?P=delim)/', $html) == 1;
 	}
 
+	/** @param array{'url'?:string,'length'?:int,'medium'?:string,'type'?:string} $enclosure */
 	private static function enclosureIsImage(array $enclosure): bool {
 		$elink = $enclosure['url'] ?? '';
 		$length = $enclosure['length'] ?? 0;
@@ -226,7 +220,7 @@ HTML;
 		return $content;
 	}
 
-	/** @return iterable<array<string,string>> */
+	/** @return iterable<array{'url':string,'type'?:string,'medium'?:string,'length'?:int,'title'?:string,'description'?:string,'credit'?:string,'height'?:int,'width'?:int,'thumbnails'?:array<string>}> */
 	public function enclosures(bool $searchBodyImages = false) {
 		$attributeEnclosures = $this->attributes('enclosures');
 		if (is_array($attributeEnclosures)) {
@@ -245,35 +239,41 @@ HTML;
 			if ($searchEnclosures) {
 				// Legacy code for database entries < FreshRSS 1.20.1
 				$enclosures = $xpath->query('//div[@class="enclosure"]/p[@class="enclosure-content"]/*[@src]');
-				foreach ($enclosures as $enclosure) {
-					$result = [
-						'url' => $enclosure->getAttribute('src'),
-						'type' => $enclosure->getAttribute('data-type'),
-						'medium' => $enclosure->getAttribute('data-medium'),
-						'length' => $enclosure->getAttribute('data-length'),
-					];
-					if (empty($result['medium'])) {
-						switch (strtolower($enclosure->nodeName)) {
-							case 'img': $result['medium'] = 'image'; break;
-							case 'video': $result['medium'] = 'video'; break;
-							case 'audio': $result['medium'] = 'audio'; break;
+				if (!empty($enclosures)) {
+					/** @var DOMElement $enclosure */
+					foreach ($enclosures as $enclosure) {
+						$result = [
+							'url' => $enclosure->getAttribute('src'),
+							'type' => $enclosure->getAttribute('data-type'),
+							'medium' => $enclosure->getAttribute('data-medium'),
+							'length' => (int)($enclosure->getAttribute('data-length')),
+						];
+						if (empty($result['medium'])) {
+							switch (strtolower($enclosure->nodeName)) {
+								case 'img': $result['medium'] = 'image'; break;
+								case 'video': $result['medium'] = 'video'; break;
+								case 'audio': $result['medium'] = 'audio'; break;
+							}
 						}
+						yield Minz_Helper::htmlspecialchars_utf8($result);
 					}
-					yield Minz_Helper::htmlspecialchars_utf8($result);
 				}
 			}
 			if ($searchBodyImages) {
 				$images = $xpath->query('//img');
-				foreach ($images as $img) {
-					$src = $img->getAttribute('src');
-					if ($src == null) {
-						$src = $img->getAttribute('data-src');
-					}
-					if ($src != null) {
-						$result = [
-							'url' => $src,
-						];
-						yield Minz_Helper::htmlspecialchars_utf8($result);
+				if (!empty($images)) {
+					/** @var DOMElement $img */
+					foreach ($images as $img) {
+						$src = $img->getAttribute('src');
+						if ($src == null) {
+							$src = $img->getAttribute('data-src');
+						}
+						if ($src != null) {
+							$result = [
+								'url' => $src,
+							];
+							yield Minz_Helper::htmlspecialchars_utf8($result);
+						}
 					}
 				}
 			}
@@ -304,7 +304,10 @@ HTML;
 	public function link(): string {
 		return $this->link;
 	}
-	/** @return string|int */
+	/**
+	 * @phpstan-return ($raw is false ? string : int)
+	 * @return string|int
+	 */
 	public function date(bool $raw = false) {
 		if ($raw) {
 			return $this->date;
@@ -314,6 +317,7 @@ HTML;
 	public function machineReadableDate(): string {
 		return @date (DATE_ATOM, $this->date);
 	}
+	/** @return int|string */
 	public function dateAdded(bool $raw = false, bool $microsecond = false) {
 		if ($raw) {
 			if ($microsecond) {
@@ -326,10 +330,10 @@ HTML;
 			return timestamptodate($date);
 		}
 	}
-	public function isRead() {
+	public function isRead(): ?bool {
 		return $this->is_read;
 	}
-	public function isFavorite() {
+	public function isFavorite(): ?bool {
 		return $this->is_favorite;
 	}
 
@@ -357,7 +361,11 @@ HTML;
 		}
 	}
 
-	public function attributes($key = '') {
+	/**
+	 * @phpstan-return ($key is non-empty-string ? mixed : array<string,mixed>)
+	 * @return array<string,mixed>|mixed
+	 */
+	public function attributes(string $key = '') {
 		if ($key == '') {
 			return $this->attributes;
 		} else {
@@ -365,7 +373,8 @@ HTML;
 		}
 	}
 
-	public function _attributes(string $key, $value) {
+	/** @param string|array<mixed>|bool|int|null $value */
+	public function _attributes(string $key, $value): void {
 		if ($key == '') {
 			if (is_string($value)) {
 				$value = json_decode($value, true);
@@ -388,7 +397,7 @@ HTML;
 		return $this->hash;
 	}
 
-	public function _hash(string $value) {
+	public function _hash(string $value): string {
 		$value = trim($value);
 		if (ctype_xdigit($value)) {
 			$this->hash = substr($value, 0, 32);
@@ -396,13 +405,13 @@ HTML;
 		return $this->hash;
 	}
 
-	public function _id($value) {
+	public function _id(string $value): void {
 		$this->id = $value;
 		if ($this->date_added == 0) {
 			$this->date_added = $value;
 		}
 	}
-	public function _guid(string $value) {
+	public function _guid(string $value): void {
 		if ($value == '') {
 			$value = $this->link;
 			if ($value == '') {
@@ -411,20 +420,21 @@ HTML;
 		}
 		$this->guid = $value;
 	}
-	public function _title(string $value) {
+	public function _title(string $value): void {
 		$this->hash = '';
 		$this->title = trim($value);
 	}
-	public function _author(string $value) {
-		//Deprecated
+	/** @deprecated */
+	public function _author(string $value): void {
 		$this->_authors($value);
 	}
-	public function _authors($value) {
+	/** @param array<string>|string $value */
+	public function _authors($value): void {
 		$this->hash = '';
 		if (!is_array($value)) {
 			if (strpos($value, ';') !== false) {
 				$value = htmlspecialchars_decode($value, ENT_QUOTES);
-				$value = preg_split('/\s*[;]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);
+				$value = preg_split('/\s*[;]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: '';
 				$value = Minz_Helper::htmlspecialchars_utf8($value);
 			} else {
 				$value = preg_split('/\s*[,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);
@@ -432,49 +442,51 @@ HTML;
 		}
 		$this->authors = $value;
 	}
-	public function _content(string $value) {
+	public function _content(string $value): void {
 		$this->hash = '';
 		$this->content = $value;
 	}
-	public function _link(string $value) {
+	public function _link(string $value): void {
 		$this->hash = '';
 		$this->link = $value;
 	}
-	public function _date($value) {
+	/** @param int|string $value */
+	public function _date($value): void {
 		$this->hash = '';
 		$value = intval($value);
 		$this->date = $value > 1 ? $value : time();
 	}
-	public function _dateAdded($value, bool $microsecond = false) {
+	/** @param int|string $value */
+	public function _dateAdded($value, bool $microsecond = false): void {
 		if ($microsecond) {
 			$this->date_added = $value;
 		} else {
-			$this->date_added = $value * 1000000;
+			$this->date_added = $value . '000000';
 		}
 	}
-	public function _isRead($value) {
+	public function _isRead(?bool $value): void {
 		$this->is_read = $value === null ? null : (bool)$value;
 	}
-	public function _isFavorite($value) {
+	public function _isFavorite(?bool $value): void {
 		$this->is_favorite = $value === null ? null : (bool)$value;
 	}
 
-	/** @param FreshRSS_Feed|null $feed */
-	public function _feed($feed) {
+	public function _feed(?FreshRSS_Feed $feed): void {
 		$this->feed = $feed;
 		$this->feedId = $this->feed == null ? 0 : $this->feed->id();
 	}
 
 	/** @param int|string $id */
-	private function _feedId($id) {
+	private function _feedId($id): void {
 		$this->feed = null;
 		$this->feedId = intval($id);
 	}
 
-	public function _tags($value) {
+	/** @param array<string>|string $value */
+	public function _tags($value): void {
 		$this->hash = '';
 		if (!is_array($value)) {
-			$value = preg_split('/\s*[#,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);
+			$value = preg_split('/\s*[#,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
 		}
 		$this->tags = $value;
 	}
@@ -494,7 +506,13 @@ HTML;
 			} elseif ($filter instanceof FreshRSS_Search) {
 				// Searches are combined by OR and are not recursive
 				$ok = true;
-				if ($filter->getMinDate()) {
+				if ($filter->getEntryIds()) {
+					$ok &= in_array($this->id, $filter->getEntryIds());
+				}
+				if ($ok && $filter->getNotEntryIds()) {
+					$ok &= !in_array($this->id, $filter->getNotEntryIds());
+				}
+				if ($ok && $filter->getMinDate()) {
 					$ok &= strnatcmp($this->id, $filter->getMinDate() . '000000') >= 0;
 				}
 				if ($ok && $filter->getNotMinDate()) {
@@ -594,7 +612,8 @@ HTML;
 		return $ok;
 	}
 
-	public function applyFilterActions(array $titlesAsRead = []) {
+	/** @param array<string,int> $titlesAsRead  */
+	public function applyFilterActions(array $titlesAsRead = []): void {
 		if ($this->feed != null) {
 			if ($this->feed->attributes('read_upon_reception') ||
 				($this->feed->attributes('read_upon_reception') === null && FreshRSS_Context::$user_conf->mark_when['reception'])) {
@@ -741,6 +760,7 @@ HTML;
 		return false;
 	}
 
+	/** @return array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'hash':string,'is_read':?bool,'is_favorite':?bool,'id_feed':int,'tags':string,'attributes':array<string,mixed>} */
 	public function toArray(): array {
 		return array(
 			'id' => $this->id(),

+ 54 - 70
app/Models/EntryDAO.php

@@ -787,26 +787,22 @@ SQL;
 			// Searches are combined by OR and are not recursive
 			$sub_search = '';
 			if ($filter->getEntryIds()) {
-				foreach ($filter->getEntryIds() as $entry_ids) {
-					$sub_search .= 'AND ' . $alias . 'id IN (';
-					foreach ($entry_ids as $entry_id) {
-						$sub_search .= '?,';
-						$values[] = $entry_id;
-					}
-					$sub_search = rtrim($sub_search, ',');
-					$sub_search .= ') ';
+				$sub_search .= 'AND ' . $alias . 'id IN (';
+				foreach ($filter->getEntryIds() as $entry_id) {
+					$sub_search .= '?,';
+					$values[] = $entry_id;
 				}
+				$sub_search = rtrim($sub_search, ',');
+				$sub_search .= ') ';
 			}
 			if ($filter->getNotEntryIds()) {
-				foreach ($filter->getNotEntryIds() as $entry_ids) {
-					$sub_search .= 'AND ' . $alias . 'id NOT IN (';
-					foreach ($entry_ids as $entry_id) {
-						$sub_search .= '?,';
-						$values[] = $entry_id;
-					}
-					$sub_search = rtrim($sub_search, ',');
-					$sub_search .= ') ';
+				$sub_search .= 'AND ' . $alias . 'id NOT IN (';
+				foreach ($filter->getNotEntryIds() as $entry_id) {
+					$sub_search .= '?,';
+					$values[] = $entry_id;
 				}
+				$sub_search = rtrim($sub_search, ',');
+				$sub_search .= ') ';
 			}
 
 			if ($filter->getMinDate()) {
@@ -859,80 +855,68 @@ SQL;
 			}
 
 			if ($filter->getFeedIds()) {
-				foreach ($filter->getFeedIds() as $feed_ids) {
-					$sub_search .= 'AND ' . $alias . 'id_feed IN (';
-					foreach ($feed_ids as $feed_id) {
-						$sub_search .= '?,';
-						$values[] = $feed_id;
-					}
-					$sub_search = rtrim($sub_search, ',');
-					$sub_search .= ') ';
+				$sub_search .= 'AND ' . $alias . 'id_feed IN (';
+				foreach ($filter->getFeedIds() as $feed_id) {
+					$sub_search .= '?,';
+					$values[] = $feed_id;
 				}
+				$sub_search = rtrim($sub_search, ',');
+				$sub_search .= ') ';
 			}
 			if ($filter->getNotFeedIds()) {
-				foreach ($filter->getNotFeedIds() as $feed_ids) {
-					$sub_search .= 'AND ' . $alias . 'id_feed NOT IN (';
-					foreach ($feed_ids as $feed_id) {
-						$sub_search .= '?,';
-						$values[] = $feed_id;
-					}
-					$sub_search = rtrim($sub_search, ',');
-					$sub_search .= ') ';
+				$sub_search .= 'AND ' . $alias . 'id_feed NOT IN (';
+				foreach ($filter->getNotFeedIds() as $feed_id) {
+					$sub_search .= '?,';
+					$values[] = $feed_id;
 				}
+				$sub_search = rtrim($sub_search, ',');
+				$sub_search .= ') ';
 			}
 
 			if ($filter->getLabelIds()) {
-				foreach ($filter->getLabelIds() as $label_ids) {
-					if ($label_ids === '*') {
-						$sub_search .= 'AND EXISTS (SELECT et.id_tag FROM `_entrytag` et WHERE et.id_entry = ' . $alias . 'id) ';
-					} else {
-						$sub_search .= 'AND ' . $alias . 'id IN (SELECT et.id_entry FROM `_entrytag` et WHERE et.id_tag IN (';
-						foreach ($label_ids as $label_id) {
-							$sub_search .= '?,';
-							$values[] = $label_id;
-						}
-						$sub_search = rtrim($sub_search, ',');
-						$sub_search .= ')) ';
+				if ($filter->getLabelIds() === '*') {
+					$sub_search .= 'AND EXISTS (SELECT et.id_tag FROM `_entrytag` et WHERE et.id_entry = ' . $alias . 'id) ';
+				} else {
+					$sub_search .= 'AND ' . $alias . 'id IN (SELECT et.id_entry FROM `_entrytag` et WHERE et.id_tag IN (';
+					foreach ($filter->getLabelIds() as $label_id) {
+						$sub_search .= '?,';
+						$values[] = $label_id;
 					}
+					$sub_search = rtrim($sub_search, ',');
+					$sub_search .= ')) ';
 				}
 			}
 			if ($filter->getNotLabelIds()) {
-				foreach ($filter->getNotLabelIds() as $label_ids) {
-					if ($label_ids === '*') {
-						$sub_search .= 'AND NOT EXISTS (SELECT et.id_tag FROM `_entrytag` et WHERE et.id_entry = ' . $alias . 'id) ';
-					} else {
-						$sub_search .= 'AND ' . $alias . 'id NOT IN (SELECT et.id_entry FROM `_entrytag` et WHERE et.id_tag IN (';
-						foreach ($label_ids as $label_id) {
-							$sub_search .= '?,';
-							$values[] = $label_id;
-						}
-						$sub_search = rtrim($sub_search, ',');
-						$sub_search .= ')) ';
+				if ($filter->getNotLabelIds() === '*') {
+					$sub_search .= 'AND NOT EXISTS (SELECT et.id_tag FROM `_entrytag` et WHERE et.id_entry = ' . $alias . 'id) ';
+				} else {
+					$sub_search .= 'AND ' . $alias . 'id NOT IN (SELECT et.id_entry FROM `_entrytag` et WHERE et.id_tag IN (';
+					foreach ($filter->getNotLabelIds() as $label_id) {
+						$sub_search .= '?,';
+						$values[] = $label_id;
 					}
+					$sub_search = rtrim($sub_search, ',');
+					$sub_search .= ')) ';
 				}
 			}
 
 			if ($filter->getLabelNames()) {
-				foreach ($filter->getLabelNames() as $label_names) {
-					$sub_search .= 'AND ' . $alias . 'id IN (SELECT et.id_entry FROM `_entrytag` et, `_tag` t WHERE et.id_tag = t.id AND t.name IN (';
-					foreach ($label_names as $label_name) {
-						$sub_search .= '?,';
-						$values[] = $label_name;
-					}
-					$sub_search = rtrim($sub_search, ',');
-					$sub_search .= ')) ';
+				$sub_search .= 'AND ' . $alias . 'id IN (SELECT et.id_entry FROM `_entrytag` et, `_tag` t WHERE et.id_tag = t.id AND t.name IN (';
+				foreach ($filter->getLabelNames() as $label_name) {
+					$sub_search .= '?,';
+					$values[] = $label_name;
 				}
+				$sub_search = rtrim($sub_search, ',');
+				$sub_search .= ')) ';
 			}
 			if ($filter->getNotLabelNames()) {
-				foreach ($filter->getNotLabelNames() as $label_names) {
-					$sub_search .= 'AND ' . $alias . 'id NOT IN (SELECT et.id_entry FROM `_entrytag` et, `_tag` t WHERE et.id_tag = t.id AND t.name IN (';
-					foreach ($label_names as $label_name) {
-						$sub_search .= '?,';
-						$values[] = $label_name;
-					}
-					$sub_search = rtrim($sub_search, ',');
-					$sub_search .= ')) ';
+				$sub_search .= 'AND ' . $alias . 'id NOT IN (SELECT et.id_entry FROM `_entrytag` et, `_tag` t WHERE et.id_tag = t.id AND t.name IN (';
+				foreach ($filter->getNotLabelNames() as $label_name) {
+					$sub_search .= '?,';
+					$values[] = $label_name;
 				}
+				$sub_search = rtrim($sub_search, ',');
+				$sub_search .= ')) ';
 			}
 
 			if ($filter->getAuthor()) {

+ 106 - 43
app/Models/Search.php

@@ -10,36 +10,65 @@ require_once(LIB_PATH . '/lib_date.php');
  */
 class FreshRSS_Search {
 
-	// This contains the user input string
+	/**
+	 * This contains the user input string
+	 * @var string
+	 */
 	private $raw_input = '';
 
 	// The following properties are extracted from the raw input
+	/** @var array<string>|null */
 	private $entry_ids;
+	/** @var array<int>|null */
 	private $feed_ids;
+	/** @var array<int>|'*'|null */
 	private $label_ids;
+	/** @var array<string>|null */
 	private $label_names;
+	/** @var array<string>|null */
 	private $intitle;
+	/** @var int|false|null */
 	private $min_date;
+	/** @var int|false|null */
 	private $max_date;
+	/** @var int|false|null */
 	private $min_pubdate;
+	/** @var int|false|null */
 	private $max_pubdate;
+	/** @var array<string>|null */
 	private $inurl;
+	/** @var array<string>|null */
 	private $author;
+	/** @var array<string>|null */
 	private $tags;
+	/** @var array<string>|null */
 	private $search;
 
+	/** @var array<string>|null */
 	private $not_entry_ids;
+	/** @var array<int>|null */
 	private $not_feed_ids;
+	/** @var array<int>|'*'|null */
 	private $not_label_ids;
+	/** @var array<string>|null */
 	private $not_label_names;
+	/** @var array<string>|null */
 	private $not_intitle;
+	/** @var int|false|null */
 	private $not_min_date;
+	/** @var int|false|null */
 	private $not_max_date;
+	/** @var int|false|null */
 	private $not_min_pubdate;
+	/** @var int|false|null */
 	private $not_max_pubdate;
+	/** @var array<string>|null */
 	private $not_inurl;
+	/** @var array<string>|null */
 	private $not_author;
+	/** @var array<string>|null */
 	private $not_tags;
+	/** @var array<string>|null */
 	private $not_search;
 
 	/**
@@ -82,114 +111,140 @@ class FreshRSS_Search {
 		$this->parseSearch($input);
 	}
 
-	public function __toString() {
+	public function __toString(): string {
 		return $this->getRawInput();
 	}
 
-	public function getRawInput() {
+	public function getRawInput(): string {
 		return $this->raw_input;
 	}
 
-	public function getEntryIds() {
+	/** @return array<string>|null */
+	public function getEntryIds(): ?array {
 		return $this->entry_ids;
 	}
-	public function getNotEntryIds() {
+	/** @return array<string>|null */
+	public function getNotEntryIds(): ?array {
 		return $this->not_entry_ids;
 	}
 
-	public function getFeedIds() {
+	/** @return array<int>|null */
+	public function getFeedIds(): ?array {
 		return $this->feed_ids;
 	}
-	public function getNotFeedIds() {
+	/** @return array<int>|null */
+	public function getNotFeedIds(): ?array {
 		return $this->not_feed_ids;
 	}
 
+	/** @return array<int>|'*'|null */
 	public function getLabelIds() {
 		return $this->label_ids;
 	}
+	/** @return array<int>|'*'|null */
 	public function getNotlabelIds() {
 		return $this->not_label_ids;
 	}
-	public function getLabelNames() {
+	/** @return array<string>|null */
+	public function getLabelNames(): ?array {
 		return $this->label_names;
 	}
-	public function getNotlabelNames() {
+	/** @return array<string>|null */
+	public function getNotlabelNames(): ?array {
 		return $this->not_label_names;
 	}
 
-	public function getIntitle() {
+	/** @return array<string>|null */
+	public function getIntitle(): ?array {
 		return $this->intitle;
 	}
-	public function getNotIntitle() {
+	/** @return array<string>|null */
+	public function getNotIntitle(): ?array {
 		return $this->not_intitle;
 	}
 
-	public function getMinDate() {
+	public function getMinDate(): ?int {
 		return $this->min_date;
 	}
-	public function getNotMinDate() {
+	public function getNotMinDate(): ?int {
 		return $this->not_min_date;
 	}
-	public function setMinDate($value) {
-		return $this->min_date = $value;
+	public function setMinDate(int $value): void {
+		$this->min_date = $value;
 	}
 
-	public function getMaxDate() {
+	public function getMaxDate(): ?int {
 		return $this->max_date;
 	}
-	public function getNotMaxDate() {
+	public function getNotMaxDate(): ?int {
 		return $this->not_max_date;
 	}
-	public function setMaxDate($value) {
-		return $this->max_date = $value;
+	public function setMaxDate(int $value): void {
+		$this->max_date = $value;
 	}
 
-	public function getMinPubdate() {
+	public function getMinPubdate(): ?int {
 		return $this->min_pubdate;
 	}
-	public function getNotMinPubdate() {
+	public function getNotMinPubdate(): ?int {
 		return $this->not_min_pubdate;
 	}
 
-	public function getMaxPubdate() {
+	public function getMaxPubdate(): ?int {
 		return $this->max_pubdate;
 	}
-	public function getNotMaxPubdate() {
+	public function getNotMaxPubdate(): ?int {
 		return $this->not_max_pubdate;
 	}
 
-	public function getInurl() {
+	/** @return array<string>|null */
+	public function getInurl(): ?array {
 		return $this->inurl;
 	}
-	public function getNotInurl() {
+	/** @return array<string>|null */
+	public function getNotInurl(): ?array {
 		return $this->not_inurl;
 	}
 
-	public function getAuthor() {
+	/** @return array<string>|null */
+	public function getAuthor(): ?array {
 		return $this->author;
 	}
-	public function getNotAuthor() {
+	/** @return array<string>|null */
+	public function getNotAuthor(): ?array {
 		return $this->not_author;
 	}
 
-	public function getTags() {
+	/** @return array<string>|null */
+	public function getTags(): ?array {
 		return $this->tags;
 	}
-	public function getNotTags() {
+	/** @return array<string>|null */
+	public function getNotTags(): ?array {
 		return $this->not_tags;
 	}
 
-	public function getSearch() {
+	/** @return array<string>|null */
+	public function getSearch(): ?array {
 		return $this->search;
 	}
-	public function getNotSearch() {
+	/** @return array<string>|null */
+	public function getNotSearch(): ?array {
 		return $this->not_search;
 	}
 
-	private static function removeEmptyValues($anArray) {
-		return is_array($anArray) ? array_filter($anArray, function($value) { return $value !== ''; }) : array();
+	/**
+	 * @param array<string> $anArray
+	 * @return array<string>
+	 */
+	private static function removeEmptyValues($anArray): array {
+		return empty($anArray) ? [] : array_filter($anArray, function($value) { return $value !== ''; });
 	}
 
+	/**
+	 * @param array<string>|string $value
+	 * @return ($value is array ? array<string> : string)
+	 */
 	private static function decodeSpaces($value) {
 		if (is_array($value)) {
 			for ($i = count($value) - 1; $i >= 0; $i--) {
@@ -213,7 +268,7 @@ class FreshRSS_Search {
 				$entry_ids = explode(',', $ids_list);
 				$entry_ids = self::removeEmptyValues($entry_ids);
 				if (!empty($entry_ids)) {
-					$this->entry_ids[] = $entry_ids;
+					$this->entry_ids = array_merge($this->entry_ids, $entry_ids);
 				}
 			}
 		}
@@ -229,7 +284,7 @@ class FreshRSS_Search {
 				$entry_ids = explode(',', $ids_list);
 				$entry_ids = self::removeEmptyValues($entry_ids);
 				if (!empty($entry_ids)) {
-					$this->not_entry_ids[] = $entry_ids;
+					$this->not_entry_ids = array_merge($this->not_entry_ids, $entry_ids);
 				}
 			}
 		}
@@ -244,8 +299,10 @@ class FreshRSS_Search {
 			foreach ($ids_lists as $ids_list) {
 				$feed_ids = explode(',', $ids_list);
 				$feed_ids = self::removeEmptyValues($feed_ids);
+				/** @var array<int> */
+				$feed_ids = array_map('intval', $feed_ids);
 				if (!empty($feed_ids)) {
-					$this->feed_ids[] = $feed_ids;
+					$this->feed_ids = array_merge($this->feed_ids, $feed_ids);
 				}
 			}
 		}
@@ -260,8 +317,10 @@ class FreshRSS_Search {
 			foreach ($ids_lists as $ids_list) {
 				$feed_ids = explode(',', $ids_list);
 				$feed_ids = self::removeEmptyValues($feed_ids);
+				/** @var array<int> */
+				$feed_ids = array_map('intval', $feed_ids);
 				if (!empty($feed_ids)) {
-					$this->not_feed_ids[] = $feed_ids;
+					$this->not_feed_ids = array_merge($this->not_feed_ids, $feed_ids);
 				}
 			}
 		}
@@ -278,13 +337,15 @@ class FreshRSS_Search {
 			$this->label_ids = [];
 			foreach ($ids_lists as $ids_list) {
 				if ($ids_list === '*') {
-					$this->label_ids[] = '*';
+					$this->label_ids = '*';
 					break;
 				}
 				$label_ids = explode(',', $ids_list);
 				$label_ids = self::removeEmptyValues($label_ids);
+				/** @var array<int> */
+				$label_ids = array_map('intval', $label_ids);
 				if (!empty($label_ids)) {
-					$this->label_ids[] = $label_ids;
+					$this->label_ids = array_merge($this->label_ids, $label_ids);
 				}
 			}
 		}
@@ -298,13 +359,15 @@ class FreshRSS_Search {
 			$this->not_label_ids = [];
 			foreach ($ids_lists as $ids_list) {
 				if ($ids_list === '*') {
-					$this->not_label_ids[] = '*';
+					$this->not_label_ids = '*';
 					break;
 				}
 				$label_ids = explode(',', $ids_list);
 				$label_ids = self::removeEmptyValues($label_ids);
+				/** @var array<int> */
+				$label_ids = array_map('intval', $label_ids);
 				if (!empty($label_ids)) {
-					$this->not_label_ids[] = $label_ids;
+					$this->not_label_ids = array_merge($this->not_label_ids, $label_ids);
 				}
 			}
 		}
@@ -330,7 +393,7 @@ class FreshRSS_Search {
 				$names_array = explode(',', $names_list);
 				$names_array = self::removeEmptyValues($names_array);
 				if (!empty($names_array)) {
-					$this->label_names[] = $names_array;
+					$this->label_names = array_merge($this->label_names, $names_array);
 				}
 			}
 		}
@@ -356,7 +419,7 @@ class FreshRSS_Search {
 				$names_array = explode(',', $names_list);
 				$names_array = self::removeEmptyValues($names_array);
 				if (!empty($names_array)) {
-					$this->not_label_names[] = $names_array;
+					$this->not_label_names = array_merge($this->not_label_names, $names_array);
 				}
 			}
 		}

+ 2 - 2
app/views/helpers/feed/update.phtml

@@ -400,7 +400,7 @@
 
 		<fieldset id="html_xpath">
 			<?php
-				$xpath = Minz_Helper::htmlspecialchars_utf8($this->feed->attributes('xpath'));
+				$xpath = Minz_Helper::htmlspecialchars_utf8((array)($this->feed->attributes('xpath')));
 			?>
 			<p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.help') ?></p>
 			<div class="form-group">
@@ -515,7 +515,7 @@
 
 		<div class="form-group">
 			<?php
-				$path_entries_filter = Minz_Helper::htmlspecialchars_utf8($this->feed->attributes('path_entries_filter'));
+				$path_entries_filter = Minz_Helper::htmlspecialchars_utf8((string)($this->feed->attributes('path_entries_filter')));
 			?>
 			<label class="group-name" for="path_entries_filter"><?= _t('sub.feed.css_path_filter') ?></label>
 			<div class="group-controls">

+ 6 - 1
lib/Minz/Helper.php

@@ -12,8 +12,13 @@ class Minz_Helper {
 	/**
 	 * Wrapper for htmlspecialchars.
 	 * Force UTf-8 value and can be used on array too.
+	 *
+	 * @phpstan-template T of string|array<mixed>
+	 * @phpstan-param T $var
+	 * @phpstan-return T
+	 *
 	 * @param string|array<string> $var
-	 * @return ($var is array ? array<string> : string)
+	 * @return string|array<string>
 	 */
 	public static function htmlspecialchars_utf8($var) {
 		if (is_array($var)) {

+ 5 - 4
lib/Minz/Request.php

@@ -49,8 +49,10 @@ class Minz_Request {
 			$p = self::$params[$key];
 			if (is_object($p) || $specialchars) {
 				return $p;
-			} else {
+			} elseif (is_string($p) || is_array($p)) {
 				return Minz_Helper::htmlspecialchars_utf8($p);
+			} else {
+				return $p;
 			}
 		} else {
 			return $default;
@@ -66,8 +68,7 @@ class Minz_Request {
 		return $specialchars ? Minz_Helper::htmlspecialchars_utf8(self::$params[$key]) : self::$params[$key];
 	}
 
-	/** @return bool|null */
-	public static function paramTernary(string $key) {
+	public static function paramTernary(string $key): ?bool {
 		if (isset(self::$params[$key])) {
 			$p = self::$params[$key];
 			$tp = is_string($p) ? trim($p) : true;
@@ -457,7 +458,7 @@ class Minz_Request {
 	/**
 	 * @return array<string>
 	 */
-	public static function getPreferredLanguages() {
+	public static function getPreferredLanguages(): array {
 		if (preg_match_all('/(^|,)\s*(?P<lang>[^;,]+)/', $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '', $matches)) {
 			return $matches['lang'];
 		}

+ 0 - 2
tests/phpstan-next.txt

@@ -7,10 +7,8 @@
 ./app/install.php
 ./app/Models/Category.php
 ./app/Models/CategoryDAO.php
-./app/Models/Entry.php
 ./app/Models/Feed.php
 ./app/Models/FeedDAO.php
-./app/Models/Search.php
 ./app/Models/TagDAO.php
 ./app/Services/ImportService.php
 ./cli/i18n/I18nData.php