PHPIndex

This page lists files in the current directory. You can view content, get download/execute commands for Wget, Curl, or PowerShell, or filter the list using wildcards (e.g., `*.sh`).

ActionController.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/ActionController.php'
View Content
<?php
declare(strict_types=1);

abstract class FreshRSS_ActionController extends Minz_ActionController {

	/**
	 * @var FreshRSS_View
	 */
	protected $view;

	public function __construct(string $viewType = '') {
		parent::__construct($viewType === '' ? FreshRSS_View::class : $viewType);
	}
}
AttributesTrait.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/AttributesTrait.php'
View Content
<?php
declare(strict_types=1);

/**
 * Logic to work with (JSON) attributes (for entries, feeds, categories, tags...).
 */
trait FreshRSS_AttributesTrait {
	/**
	 * @var array<string,mixed>
	 */
	private array $attributes = [];

	/** @return array<string,mixed> */
	public function attributes(): array {
		return $this->attributes;
	}

	/** @param non-empty-string $key */
	public function hasAttribute(string $key): bool {
		return isset($this->attributes[$key]);
	}

	/**
	 * @param non-empty-string $key
	 * @return array<int|string,mixed>|null
	 */
	public function attributeArray(string $key): ?array {
		$a = $this->attributes[$key] ?? null;
		return is_array($a) ? $a : null;
	}

	/** @param non-empty-string $key */
	public function attributeBoolean(string $key): ?bool {
		$a = $this->attributes[$key] ?? null;
		return is_bool($a) ? $a : null;
	}

	/** @param non-empty-string $key */
	public function attributeInt(string $key): ?int {
		$a = $this->attributes[$key] ?? null;
		return is_int($a) ? $a : null;
	}

	/** @param non-empty-string $key */
	public function attributeString(string $key): ?string {
		$a = $this->attributes[$key] ?? null;
		return is_string($a) ? $a : null;
	}

	/** @param string|array<string,mixed> $values Values, not HTML-encoded */
	public function _attributes($values): void {
		if (is_string($values)) {
			$values = json_decode($values, true);
		}
		if (is_array($values)) {
			$this->attributes = $values;
		}
	}

	/**
	 * @param non-empty-string $key
	 * @param array<string,mixed>|mixed|null $value Value, not HTML-encoded
	 */
	public function _attribute(string $key, $value = null): void {
		if ($value === null) {
			unset($this->attributes[$key]);
		} else {
			$this->attributes[$key] = $value;
		}
	}
}
Auth.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Auth.php'
View Content
<?php
declare(strict_types=1);

/**
 * This class handles all authentication process.
 */
class FreshRSS_Auth {
	/**
	 * Determines if user is connected.
	 */
	public const DEFAULT_COOKIE_DURATION = 7_776_000;

	private static bool $login_ok = false;

	/**
	 * This method initializes authentication system.
	 */
	public static function init(): bool {
		if (isset($_SESSION['REMOTE_USER']) && $_SESSION['REMOTE_USER'] !== httpAuthUser()) {
			//HTTP REMOTE_USER has changed
			self::removeAccess();
		}

		self::$login_ok = Minz_Session::paramBoolean('loginOk');
		$current_user = Minz_User::name();
		if ($current_user === null) {
			$current_user = FreshRSS_Context::systemConf()->default_user;
			Minz_Session::_params([
				Minz_User::CURRENT_USER => $current_user,
				'csrf' => false,
			]);
		}

		if (self::$login_ok && self::giveAccess()) {
			return self::$login_ok;
		}
		if (self::accessControl() && self::giveAccess()) {
			FreshRSS_UserDAO::touch();
			return self::$login_ok;
		}
		// Be sure all accesses are removed!
		self::removeAccess();
		return false;
	}

	/**
	 * This method checks if user is allowed to connect.
	 *
	 * Required session parameters are also set in this method (such as
	 * currentUser).
	 *
	 * @return bool true if user can be connected, false otherwise.
	 */
	private static function accessControl(): bool {
		$auth_type = FreshRSS_Context::systemConf()->auth_type;
		switch ($auth_type) {
			case 'form':
				$credentials = FreshRSS_FormAuth::getCredentialsFromCookie();
				$current_user = '';
				if (isset($credentials[1])) {
					$current_user = trim($credentials[0]);
					Minz_Session::_params([
					Minz_User::CURRENT_USER => $current_user,
					'passwordHash' => trim($credentials[1]),
					'csrf' => false,
					]);
				}
				return $current_user != '';
			case 'http_auth':
				$current_user = httpAuthUser();
				if ($current_user == '') {
					return false;
				}
				$login_ok = FreshRSS_UserDAO::exists($current_user);
				if (!$login_ok && FreshRSS_Context::systemConf()->http_auth_auto_register) {
					$email = null;
					if (FreshRSS_Context::systemConf()->http_auth_auto_register_email_field !== '' &&
						isset($_SERVER[FreshRSS_Context::systemConf()->http_auth_auto_register_email_field])) {
						$email = (string)$_SERVER[FreshRSS_Context::systemConf()->http_auth_auto_register_email_field];
					}
					$language = Minz_Translate::getLanguage(null, Minz_Request::getPreferredLanguages(), FreshRSS_Context::systemConf()->language);
					Minz_Translate::init($language);
					$login_ok = FreshRSS_user_Controller::createUser($current_user, $email, '', [
					'language' => $language,
					]);
				}
				if ($login_ok) {
					Minz_Session::_params([
					Minz_User::CURRENT_USER => $current_user,
					'csrf' => false,
					]);
				}
				return $login_ok;
			case 'none':
				return true;
			default:
				// TODO load extension
				return false;
		}
	}

	/**
	 * Gives access to the current user.
	 */
	public static function giveAccess(): bool {
		FreshRSS_Context::initUser();
		if (!FreshRSS_Context::hasUserConf() || !FreshRSS_Context::userConf()->enabled) {
			self::$login_ok = false;
			return false;
		}

		switch (FreshRSS_Context::systemConf()->auth_type) {
			case 'form':
				self::$login_ok = Minz_Session::paramString('passwordHash') === FreshRSS_Context::userConf()->passwordHash;
				break;
			case 'http_auth':
				$current_user = Minz_User::name() ?? '';
				self::$login_ok = strcasecmp($current_user, httpAuthUser()) === 0;
				break;
			case 'none':
				self::$login_ok = true;
				break;
			default:
				// TODO: extensions
				self::$login_ok = false;
		}

		Minz_Session::_params([
			'loginOk' => self::$login_ok,
			'REMOTE_USER' => httpAuthUser(),
		]);
		return self::$login_ok;
	}

	/**
	 * Returns if current user has access to the given scope.
	 *
	 * @param string $scope general (default) or admin
	 * @return bool true if user has corresponding access, false else.
	 */
	public static function hasAccess(string $scope = 'general'): bool {
		if (!FreshRSS_Context::hasUserConf()) {
			return false;
		}
		$currentUser = Minz_User::name();
		$isAdmin = FreshRSS_Context::userConf()->is_admin;
		$default_user = FreshRSS_Context::systemConf()->default_user;
		$ok = self::$login_ok;
		switch ($scope) {
			case 'general':
				break;
			case 'admin':
				$ok &= $default_user === $currentUser || $isAdmin;
				break;
			default:
				$ok = false;
		}
		return (bool)$ok;
	}

	/**
	 * Removes all accesses for the current user.
	 */
	public static function removeAccess(): void {
		self::$login_ok = false;
		Minz_Session::_params([
			'loginOk' => false,
			'csrf' => false,
			'REMOTE_USER' => false,
		]);

		$username = '';
		$token_param = Minz_Request::paramString('token');
		if ($token_param != '') {
			$username = Minz_Request::paramString('user');
			if ($username != '') {
				$conf = get_user_configuration($username);
				if ($conf == null) {
					$username = '';
				}
			}
		}
		if ($username == '') {
			$username = FreshRSS_Context::systemConf()->default_user;
		}
		Minz_User::change($username);

		switch (FreshRSS_Context::systemConf()->auth_type) {
			case 'form':
				Minz_Session::_param('passwordHash');
				FreshRSS_FormAuth::deleteCookie();
				break;
			case 'http_auth':
			case 'none':
				// Nothing to do…
				break;
			default:
				// TODO: extensions
		}
	}

	/**
	 * Return if authentication is enabled on this instance of FRSS.
	 */
	public static function accessNeedsLogin(): bool {
		return FreshRSS_Context::systemConf()->auth_type !== 'none';
	}

	/**
	 * Return if authentication requires a PHP action.
	 */
	public static function accessNeedsAction(): bool {
		return FreshRSS_Context::systemConf()->auth_type === 'form';
	}

	public static function csrfToken(): string {
		$csrf = Minz_Session::paramString('csrf');
		if ($csrf == '') {
			$salt = FreshRSS_Context::systemConf()->salt;
			$csrf = sha1($salt . uniqid('' . random_int(0, mt_getrandmax()), true));
			Minz_Session::_param('csrf', $csrf);
		}
		return $csrf;
	}

	public static function isCsrfOk(?string $token = null): bool {
		$csrf = Minz_Session::paramString('csrf');
		if ($token === null) {
			$token = $_POST['_csrf'] ?? '';
		}
		return $token != '' && $token === $csrf;
	}
}
BooleanSearch.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/BooleanSearch.php'
View Content
<?php
declare(strict_types=1);

/**
 * Contains Boolean search from the search form.
 */
class FreshRSS_BooleanSearch {

	private string $raw_input = '';
	/** @var array<FreshRSS_BooleanSearch|FreshRSS_Search> */
	private array $searches = [];

	/**
	 * @phpstan-var 'AND'|'OR'|'AND NOT'|'OR NOT'
	 */
	private string $operator;

	/** @param 'AND'|'OR'|'AND NOT'|'OR NOT' $operator */
	public function __construct(string $input, int $level = 0, string $operator = 'AND', bool $allowUserQueries = true) {
		$this->operator = $operator;
		$input = trim($input);
		if ($input === '') {
			return;
		}
		if ($level === 0) {
			$input = preg_replace('/:&quot;(.*?)&quot;/', ':"\1"', $input);
			if (!is_string($input)) {
				return;
			}
			$input = preg_replace('/(?<=[\s!-]|^)&quot;(.*?)&quot;/', '"\1"', $input);
			if (!is_string($input)) {
				return;
			}

			$input = $this->parseUserQueryNames($input, $allowUserQueries);
			$input = $this->parseUserQueryIds($input, $allowUserQueries);
			$input = trim($input);
		}
		$this->raw_input = $input;

		$input = self::consistentOrParentheses($input);

		// Either parse everything as a series of BooleanSearch’s combined by implicit AND
		// or parse everything as a series of Search’s combined by explicit OR
		$this->parseParentheses($input, $level) || $this->parseOrSegments($input);
	}

	/**
	 * Parse the user queries (saved searches) by name and expand them in the input string.
	 */
	private function parseUserQueryNames(string $input, bool $allowUserQueries = true): string {
		$all_matches = [];
		if (preg_match_all('/\bsearch:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matchesFound)) {
			$all_matches[] = $matchesFound;
		}
		if (preg_match_all('/\bsearch:(?P<search>[^\s"]*)/', $input, $matchesFound)) {
			$all_matches[] = $matchesFound;
		}

		if (!empty($all_matches)) {
			/** @var array<string,FreshRSS_UserQuery> */
			$queries = [];
			foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
				$query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
				$queries[$query->getName()] = $query;
			}

			$fromS = [];
			$toS = [];
			foreach ($all_matches as $matches) {
				if (empty($matches['search'])) {
					continue;
				}
				for ($i = count($matches['search']) - 1; $i >= 0; $i--) {
					$name = trim($matches['search'][$i]);
					if (!empty($queries[$name])) {
						$fromS[] = $matches[0][$i];
						if ($allowUserQueries) {
							$toS[] = '(' . trim($queries[$name]->getSearch()->getRawInput()) . ')';
						} else {
							$toS[] = '';
						}
					}
				}
			}

			$input = str_replace($fromS, $toS, $input);
		}
		return $input;
	}

	/**
	 * Parse the user queries (saved searches) by ID and expand them in the input string.
	 */
	private function parseUserQueryIds(string $input, bool $allowUserQueries = true): string {
		$all_matches = [];

		if (preg_match_all('/\bS:(?P<search>\d+)/', $input, $matchesFound)) {
			$all_matches[] = $matchesFound;
		}

		if (!empty($all_matches)) {
			/** @var array<string,FreshRSS_UserQuery> */
			$queries = [];
			foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
				$query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
				$queries[] = $query;
			}

			$fromS = [];
			$toS = [];
			foreach ($all_matches as $matches) {
				if (empty($matches['search'])) {
					continue;
				}
				for ($i = count($matches['search']) - 1; $i >= 0; $i--) {
					// Index starting from 1
					$id = (int)(trim($matches['search'][$i])) - 1;
					if (!empty($queries[$id])) {
						$fromS[] = $matches[0][$i];
						if ($allowUserQueries) {
							$toS[] = '(' . trim($queries[$id]->getSearch()->getRawInput()) . ')';
						} else {
							$toS[] = '';
						}
					}
				}
			}

			$input = str_replace($fromS, $toS, $input);
		}
		return $input;
	}

	/**
	 * Example: 'ab cd OR ef OR "gh ij"' becomes '(ab cd) OR (ef) OR ("gh ij")'
	 */
	public static function addOrParentheses(string $input): string {
		$input = trim($input);
		if ($input === '') {
			return '';
		}
		$splits = preg_split('/\b(OR)\b/i', $input, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
		$ns = count($splits);
		if ($ns <= 1) {
			return $input;
		}
		$result = '';
		$segment = '';
		for ($i = 0; $i < $ns; $i++) {
			$segment .= $splits[$i];
			if (trim($segment) === '') {
				$segment = '';
			} elseif (strcasecmp($segment, 'OR') === 0) {
				$result .= $segment . ' ';
				$segment = '';
			} else {
				$quotes = substr_count($segment, '"') + substr_count($segment, '&quot;');
				if ($quotes % 2 === 0) {
					$segment = trim($segment);
					if (in_array($segment, ['!', '-'], true)) {
						$result .= $segment;
					} else {
						$result .= '(' . $segment . ') ';
					}
					$segment = '';
				}
			}
		}
		$segment = trim($segment);
		if (in_array($segment, ['!', '-'], true)) {
			$result .= $segment;
		} elseif ($segment !== '') {
			$result .= '(' . $segment . ')';
		}
		return trim($result);
	}

	/**
	 * If the query contains a mix of `OR` expressions with and without parentheses,
	 * then add parentheses to make the query consistent.
	 * Example: '(ab (cd OR ef)) OR gh OR ij OR (kl)' becomes '(ab ((cd) OR (ef))) OR (gh) OR (ij) OR (kl)'
	 */
	public static function consistentOrParentheses(string $input): string {
		if (!preg_match('/(?<!\\\\)\\(/', $input)) {
			// No unescaped parentheses in the input
			return trim($input);
		}
		$parenthesesCount = 0;
		$result = '';
		$segment = '';
		$length = strlen($input);

		for ($i = 0; $i < $length; $i++) {
			$c = $input[$i];
			$backslashed = $i >= 1 ? $input[$i - 1] === '\\' : false;
			if (!$backslashed) {
				if ($c === '(') {
					if ($parenthesesCount === 0) {
						if ($segment !== '') {
							$result = rtrim($result) . ' ' . self::addOrParentheses($segment);
							$negation = preg_match('/[!-]$/', $result);
							if (!$negation) {
								$result .= ' ';
							}
							$segment = '';
						}
						$c = '';
					}
					$parenthesesCount++;
				} elseif ($c === ')') {
					$parenthesesCount--;
					if ($parenthesesCount === 0) {
						$segment = self::consistentOrParentheses($segment);
						if ($segment !== '') {
							$result .= '(' . $segment . ')';
							$segment = '';
						}
						$c = '';
					}
				}
			}
			$segment .= $c;
		}
		if (trim($segment) !== '') {
			$result = rtrim($result);
			$negation = preg_match('/[!-]$/', $segment);
			if (!$negation) {
				$result .= ' ';
			}
			$result .= self::addOrParentheses($segment);
		}
		return trim($result);
	}

	/** @return bool True if some parenthesis logic took over, false otherwise */
	private function parseParentheses(string $input, int $level): bool {
		$input = trim($input);
		$length = strlen($input);
		$i = 0;
		$before = '';
		$hasParenthesis = false;
		$nextOperator = 'AND';
		while ($i < $length) {
			$c = $input[$i];
			$backslashed = $i >= 1 ? $input[$i - 1] === '\\' : false;

			if ($c === '(' && !$backslashed) {
				$hasParenthesis = true;

				$before = trim($before);
				if (preg_match('/[!-]$/', $before)) {
					// Trim trailing negation
					$before = rtrim($before, ' !-');
					$isOr = preg_match('/\bOR$/i', $before);
					if ($isOr) {
						// Trim trailing OR
						$before = substr($before, 0, -2);
					}

					// The text prior to the negation is a BooleanSearch
					$searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
					if (count($searchBefore->searches()) > 0) {
						$this->searches[] = $searchBefore;
					}
					$before = '';

					// The next BooleanSearch will have to be combined with AND NOT or OR NOT instead of default AND
					$nextOperator = $isOr ? 'OR NOT' : 'AND NOT';
				} elseif (preg_match('/\bOR$/i', $before)) {
					// Trim trailing OR
					$before = substr($before, 0, -2);

					// The text prior to the OR is a BooleanSearch
					$searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
					if (count($searchBefore->searches()) > 0) {
						$this->searches[] = $searchBefore;
					}
					$before = '';

					// The next BooleanSearch will have to be combined with OR instead of default AND
					$nextOperator = 'OR';
				} elseif ($before !== '') {
					// The text prior to the opening parenthesis is a BooleanSearch
					$searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
					if (count($searchBefore->searches()) > 0) {
						$this->searches[] = $searchBefore;
					}
					$before = '';
				}

				// Search the matching closing parenthesis
				$parentheses = 1;
				$sub = '';
				$i++;
				while ($i < $length) {
					$c = $input[$i];
					$backslashed = $input[$i - 1] === '\\';
					if ($c === '(' && !$backslashed) {
						// One nested level deeper
						$parentheses++;
						$sub .= $c;
					} elseif ($c === ')' && !$backslashed) {
						$parentheses--;
						if ($parentheses === 0) {
							// Found the matching closing parenthesis
							$searchSub = new FreshRSS_BooleanSearch($sub, $level + 1, $nextOperator);
							$nextOperator = 'AND';
							if (count($searchSub->searches()) > 0) {
								$this->searches[] = $searchSub;
							}
							$sub = '';
							break;
						} else {
							$sub .= $c;
						}
					} else {
						$sub .= $c;
					}
					$i++;
				}
				// $sub = trim($sub);
				// if ($sub !== '') {
				// 	// TODO: Consider throwing an error or warning in case of non-matching parenthesis
				// }
			// } elseif ($c === ')') {
			// 	// TODO: Consider throwing an error or warning in case of non-matching parenthesis
			} else {
				$before .= $c;
			}
			$i++;
		}
		if ($hasParenthesis) {
			$before = trim($before);
			if (preg_match('/^OR\b/i', $before)) {
				// The next BooleanSearch will have to be combined with OR instead of default AND
				$nextOperator = 'OR';
				// Trim leading OR
				$before = substr($before, 2);
			}

			// The remaining text after the last parenthesis is a BooleanSearch
			$searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
			$nextOperator = 'AND';
			if (count($searchBefore->searches()) > 0) {
				$this->searches[] = $searchBefore;
			}
			return true;
		}
		// There was no parenthesis logic to apply
		return false;
	}

	private function parseOrSegments(string $input): void {
		$input = trim($input);
		if ($input === '') {
			return;
		}
		$splits = preg_split('/\b(OR)\b/i', $input, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
		$segment = '';
		$ns = count($splits);
		for ($i = 0; $i < $ns; $i++) {
			$segment = $segment . $splits[$i];
			if (trim($segment) === '' || strcasecmp($segment, 'OR') === 0) {
				$segment = '';
			} else {
				$quotes = substr_count($segment, '"') + substr_count($segment, '&quot;');
				if ($quotes % 2 === 0) {
					$segment = trim($segment);
					$this->searches[] = new FreshRSS_Search($segment);
					$segment = '';
				}
			}
		}
		$segment = trim($segment);
		if ($segment !== '') {
			$this->searches[] = new FreshRSS_Search($segment);
		}
	}

	/**
	 * Either a list of FreshRSS_BooleanSearch combined by implicit AND
	 * or a series of FreshRSS_Search combined by explicit OR
	 * @return array<FreshRSS_BooleanSearch|FreshRSS_Search>
	 */
	public function searches(): array {
		return $this->searches;
	}

	/** @return 'AND'|'OR'|'AND NOT'|'OR NOT' depending on how this BooleanSearch should be combined */
	public function operator(): string {
		return $this->operator;
	}

	/** @param FreshRSS_BooleanSearch|FreshRSS_Search $search */
	public function add($search): void {
		$this->searches[] = $search;
	}

	#[\Override]
	public function __toString(): string {
		return $this->getRawInput();
	}

	public function getRawInput(): string {
		return $this->raw_input;
	}
}
Category.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Category.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_Category extends Minz_Model {
	use FreshRSS_AttributesTrait, FreshRSS_FilterActionsTrait;

	/**
	 * Normal
	 */
	public const KIND_NORMAL = 0;

	/**
	 * Category tracking a third-party Dynamic OPML
	 */
	public const KIND_DYNAMIC_OPML = 2;

	private int $id = 0;
	private int $kind = 0;
	private string $name;
	private int $nbFeeds = -1;
	private int $nbNotRead = -1;
	/** @var array<FreshRSS_Feed>|null */
	private ?array $feeds = null;
	/** @var bool|int */
	private $hasFeedsWithError = false;
	private int $lastUpdate = 0;
	private bool $error = false;

	/**
	 * @param array<FreshRSS_Feed>|null $feeds
	 */
	public function __construct(string $name = '', int $id = 0, ?array $feeds = null) {
		$this->_id($id);
		$this->_name($name);
		if ($feeds !== null) {
			$this->_feeds($feeds);
			$this->nbFeeds = 0;
			$this->nbNotRead = 0;
			foreach ($feeds as $feed) {
				$feed->_category($this);
				$this->nbFeeds++;
				$this->nbNotRead += $feed->nbNotRead();
				$this->hasFeedsWithError |= ($feed->inError() && !$feed->mute());
			}
		}
	}

	public function id(): int {
		return $this->id;
	}
	public function kind(): int {
		return $this->kind;
	}
	/** @return string HTML-encoded name of the category */
	public function name(): string {
		return $this->name;
	}
	public function lastUpdate(): int {
		return $this->lastUpdate;
	}
	public function _lastUpdate(int $value): void {
		$this->lastUpdate = $value;
	}
	public function inError(): bool {
		return $this->error;
	}

	/** @param bool|int $value */
	public function _error($value): void {
		$this->error = (bool)$value;
	}
	public function isDefault(): bool {
		return $this->id == FreshRSS_CategoryDAO::DEFAULTCATEGORYID;
	}
	public function nbFeeds(): int {
		if ($this->nbFeeds < 0) {
			$catDAO = FreshRSS_Factory::createCategoryDao();
			$this->nbFeeds = $catDAO->countFeed($this->id());
		}

		return $this->nbFeeds;
	}

	/**
	 * @throws Minz_ConfigurationNamespaceException
	 * @throws Minz_PDOConnectionException
	 */
	public function nbNotRead(): int {
		if ($this->nbNotRead < 0) {
			$catDAO = FreshRSS_Factory::createCategoryDao();
			$this->nbNotRead = $catDAO->countNotRead($this->id());
		}

		return $this->nbNotRead;
	}

	/** @return array<int,mixed> */
	public function curlOptions(): array {
		return [];	// TODO (e.g., credentials for Dynamic OPML)
	}

	/**
	 * @return array<int,FreshRSS_Feed>
	 * @throws Minz_ConfigurationNamespaceException
	 * @throws Minz_PDOConnectionException
	 */
	public function feeds(): array {
		if ($this->feeds === null) {
			$feedDAO = FreshRSS_Factory::createFeedDao();
			$this->feeds = $feedDAO->listByCategory($this->id());
			$this->nbFeeds = 0;
			$this->nbNotRead = 0;
			foreach ($this->feeds as $feed) {
				$this->nbFeeds++;
				$this->nbNotRead += $feed->nbNotRead();
				$this->hasFeedsWithError |= ($feed->inError() && !$feed->mute());
			}
			$this->sortFeeds();
		}
		return $this->feeds ?? [];
	}

	public function hasFeedsWithError(): bool {
		return (bool)($this->hasFeedsWithError);
	}

	public function _id(int $id): void {
		$this->id = $id;
		if ($id === FreshRSS_CategoryDAO::DEFAULTCATEGORYID) {
			$this->name = _t('gen.short.default_category');
		}
	}

	public function _kind(int $kind): void {
		$this->kind = $kind;
	}

	public function _name(string $value): void {
		if ($this->id !== FreshRSS_CategoryDAO::DEFAULTCATEGORYID) {
			$this->name = mb_strcut(trim($value), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
		}
	}

	/** @param array<FreshRSS_Feed>|FreshRSS_Feed $values */
	public function _feeds($values): void {
		if (!is_array($values)) {
			$values = [$values];
		}
		$this->feeds = $values;
		$this->sortFeeds();
	}

	/**
	 * To manually add feeds to this category (not committing to database).
	 */
	public function addFeed(FreshRSS_Feed $feed): void {
		if ($this->feeds === null) {
			$this->feeds = [];
		}
		$feed->_category($this);
		$this->feeds[] = $feed;
		$this->sortFeeds();
	}

	/**
	 * @throws FreshRSS_Context_Exception
	 */
	public function cacheFilename(string $url): string {
		$simplePie = customSimplePie($this->attributes(), $this->curlOptions());
		$filename = $simplePie->get_cache_filename($url);
		return CACHE_PATH . '/' . $filename . '.opml.xml';
	}

	public function refreshDynamicOpml(): bool {
		$url = $this->attributeString('opml_url');
		if ($url == null) {
			return false;
		}
		$ok = true;
		$cachePath = $this->cacheFilename($url);
		$opml = httpGet($url, $cachePath, 'opml', $this->attributes(), $this->curlOptions());
		if ($opml == '') {
			Minz_Log::warning('Error getting dynamic OPML for category ' . $this->id() . '! ' .
				SimplePie_Misc::url_remove_credentials($url));
			$ok = false;
		} else {
			$dryRunCategory = new FreshRSS_Category();
			$importService = new FreshRSS_Import_Service();
			$importService->importOpml($opml, $dryRunCategory, true);
			if ($importService->lastStatus()) {
				$feedDAO = FreshRSS_Factory::createFeedDao();

				/** @var array<string,FreshRSS_Feed> */
				$dryRunFeeds = [];
				foreach ($dryRunCategory->feeds() as $dryRunFeed) {
					$dryRunFeeds[$dryRunFeed->url()] = $dryRunFeed;
				}

				/** @var array<string,FreshRSS_Feed> */
				$existingFeeds = [];
				foreach ($this->feeds() as $existingFeed) {
					$existingFeeds[$existingFeed->url()] = $existingFeed;
					if (empty($dryRunFeeds[$existingFeed->url()])) {
						// The feed does not exist in the new dynamic OPML, so mute (disable) that feed
						$existingFeed->_mute(true);
						$ok &= ($feedDAO->updateFeed($existingFeed->id(), [
							'ttl' => $existingFeed->ttl(true),
						]) !== false);
					}
				}

				foreach ($dryRunCategory->feeds() as $dryRunFeed) {
					if (empty($existingFeeds[$dryRunFeed->url()])) {
						// The feed does not exist in the current category, so add that feed
						$dryRunFeed->_category($this);
						$ok &= ($feedDAO->addFeedObject($dryRunFeed) !== false);
						$existingFeeds[$dryRunFeed->url()] = $dryRunFeed;
					} else {
						$existingFeed = $existingFeeds[$dryRunFeed->url()];
						if ($existingFeed->mute()) {
							// The feed already exists in the current category but was muted (disabled), so unmute (enable) again
							$existingFeed->_mute(false);
							$ok &= ($feedDAO->updateFeed($existingFeed->id(), [
								'ttl' => $existingFeed->ttl(true),
							]) !== false);
						}
					}
				}
			} else {
				$ok = false;
				Minz_Log::warning('Error loading dynamic OPML for category ' . $this->id() . '! ' .
					SimplePie_Misc::url_remove_credentials($url));
			}
		}

		$catDAO = FreshRSS_Factory::createCategoryDao();
		$catDAO->updateLastUpdate($this->id(), !$ok);

		return (bool)$ok;
	}

	private function sortFeeds(): void {
		if ($this->feeds === null) {
			return;
		}
		uasort($this->feeds, static function (FreshRSS_Feed $a, FreshRSS_Feed $b) {
			return strnatcasecmp($a->name(), $b->name());
		});
	}

	/**
	 * Access cached feed
	 * @param array<FreshRSS_Category> $categories
	 */
	public static function findFeed(array $categories, int $feed_id): ?FreshRSS_Feed {
		foreach ($categories as $category) {
			foreach ($category->feeds() as $feed) {
				if ($feed->id() === $feed_id) {
					$feed->_category($category);	// Should already be done; just to be safe
					return $feed;
				}
			}
		}
		return null;
	}

	/**
	 * Access cached feeds
	 * @param array<FreshRSS_Category> $categories
	 * @return array<int,FreshRSS_Feed>
	 */
	public static function findFeeds(array $categories): array {
		$result = [];
		foreach ($categories as $category) {
			foreach ($category->feeds() as $feed) {
				$result[$feed->id()] = $feed;
			}
		}
		return $result;
	}

	/**
	 * @param array<FreshRSS_Category> $categories
	 */
	public static function countUnread(array $categories, int $minPriority = 0): int {
		$n = 0;
		foreach ($categories as $category) {
			foreach ($category->feeds() as $feed) {
				if ($feed->priority() >= $minPriority) {
					$n += $feed->nbNotRead();
				}
			}
		}
		return $n;
	}
}
CategoryDAO.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/CategoryDAO.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_CategoryDAO extends Minz_ModelPdo {

	public const DEFAULTCATEGORYID = 1;

	public function resetDefaultCategoryName(): bool {
		//FreshRSS 1.15.1
		$stm = $this->pdo->prepare('UPDATE `_category` SET name = :name WHERE id = :id');
		if ($stm !== false) {
			$stm->bindValue(':id', self::DEFAULTCATEGORYID, PDO::PARAM_INT);
			$stm->bindValue(':name', 'Uncategorized');
		}
		return $stm && $stm->execute();
	}

	protected function addColumn(string $name): bool {
		if ($this->pdo->inTransaction()) {
			$this->pdo->commit();
		}
		Minz_Log::warning(__method__ . ': ' . $name);
		try {
			if ($name === 'kind') {	//v1.20.0
				return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN kind SMALLINT DEFAULT 0') !== false;
			} elseif ($name === 'lastUpdate') {	//v1.20.0
				return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN `lastUpdate` BIGINT DEFAULT 0') !== false;
			} elseif ($name === 'error') {	//v1.20.0
				return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN error SMALLINT DEFAULT 0') !== false;
			} elseif ('attributes' === $name) {	//v1.15.0
				$ok = $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN attributes TEXT') !== false;

				/** @var array<array{'id':int,'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
				 * 	'priority':int,'pathEntries':string,'httpAuth':string,'error':int,'keep_history':?int,'ttl':int,'attributes':string}> $feeds */
				$feeds = $this->fetchAssoc('SELECT * FROM `_feed`') ?? [];

				$stm = $this->pdo->prepare('UPDATE `_feed` SET attributes = :attributes WHERE id = :id');
				if ($stm === false) {
					Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
					return false;
				}
				foreach ($feeds as $feed) {
					if (empty($feed['keep_history']) || empty($feed['id'])) {
						continue;
					}
					$keepHistory = $feed['keep_history'];
					$attributes = empty($feed['attributes']) ? [] : json_decode($feed['attributes'], true);
					if (is_string($attributes)) {	//Legacy risk of double-encoding
						$attributes = json_decode($attributes, true);
					}
					if (!is_array($attributes)) {
						$attributes = [];
					}
					if ($keepHistory > 0) {
						$attributes['archiving']['keep_min'] = (int)$keepHistory;
					} elseif ($keepHistory == -1) {	//Infinite
						$attributes['archiving']['keep_period'] = false;
						$attributes['archiving']['keep_max'] = false;
						$attributes['archiving']['keep_min'] = false;
					} else {
						continue;
					}
					if (!($stm->bindValue(':id', $feed['id'], PDO::PARAM_INT) &&
						$stm->bindValue(':attributes', json_encode($attributes, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) &&
						$stm->execute())) {
						Minz_Log::error('SQL error ' . __METHOD__ . json_encode($stm->errorInfo()));
					}
				}

				if ($this->pdo->dbType() !== 'sqlite') {	//SQLite does not support DROP COLUMN
					$this->pdo->exec('ALTER TABLE `_feed` DROP COLUMN keep_history');
				} else {
					$this->pdo->exec('DROP INDEX IF EXISTS feed_keep_history_index');	//SQLite at least drop index
				}

				$this->resetDefaultCategoryName();

				return $ok;
			}
		} catch (Exception $e) {
			Minz_Log::error(__method__ . ': ' . $e->getMessage());
		}
		return false;
	}

	/** @param array<string|int> $errorInfo */
	protected function autoUpdateDb(array $errorInfo): bool {
		if (isset($errorInfo[0])) {
			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
				$errorLines = explode("\n", (string)$errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
				foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) {
					if (stripos($errorLines[0], $column) !== false) {
						return $this->addColumn($column);
					}
				}
			}
		}
		return false;
	}

	/**
	 * @param array{'name':string,'id'?:int,'kind'?:int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string|array<string,mixed>} $valuesTmp
	 * @return int|false
	 */
	public function addCategory(array $valuesTmp) {
		// TRIM() to provide a type hint as text
		// No tag of the same name
		$sql = <<<'SQL'
INSERT INTO `_category`(kind, name, attributes)
SELECT * FROM (SELECT ABS(?) AS kind, TRIM(?) AS name, TRIM(?) AS attributes) c2
WHERE NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = TRIM(?))
SQL;
		$stm = $this->pdo->prepare($sql);

		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
		if (!isset($valuesTmp['attributes'])) {
			$valuesTmp['attributes'] = [];
		}
		$values = [
			$valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL,
			$valuesTmp['name'],
			is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
			$valuesTmp['name'],
		];

		if ($stm !== false && $stm->execute($values) && $stm->rowCount() > 0) {
			$catId = $this->pdo->lastInsertId('`_category_id_seq`');
			return $catId === false ? false : (int)$catId;
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			if ($this->autoUpdateDb($info)) {
				return $this->addCategory($valuesTmp);
			}
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/** @return int|false */
	public function addCategoryObject(FreshRSS_Category $category) {
		$cat = $this->searchByName($category->name());
		if (!$cat) {
			$values = [
				'kind' => $category->kind(),
				'name' => $category->name(),
				'attributes' => $category->attributes(),
			];
			return $this->addCategory($values);
		}

		return $cat->id();
	}

	/**
	 * @param array{'name':string,'kind':int,'attributes'?:array<string,mixed>|mixed|null} $valuesTmp
	 * @return int|false
	 */
	public function updateCategory(int $id, array $valuesTmp) {
		// No tag of the same name
		$sql = <<<'SQL'
UPDATE `_category` SET name=?, kind=?, attributes=? WHERE id=?
AND NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = ?)
SQL;
		$stm = $this->pdo->prepare($sql);

		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
		if (empty($valuesTmp['attributes'])) {
			$valuesTmp['attributes'] = [];
		}
		$values = [
			$valuesTmp['name'],
			$valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL,
			is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
			$id,
			$valuesTmp['name'],
		];

		if ($stm !== false && $stm->execute($values)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			if ($this->autoUpdateDb($info)) {
				return $this->updateCategory($id, $valuesTmp);
			}
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/** @return int|false */
	public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0) {
		$sql = 'UPDATE `_category` SET `lastUpdate`=?, error=? WHERE id=?';
		$values = [
			$mtime <= 0 ? time() : $mtime,
			$inError ? 1 : 0,
			$id,
		];
		$stm = $this->pdo->prepare($sql);

		if ($stm !== false && $stm->execute($values)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/** @return int|false */
	public function deleteCategory(int $id) {
		if ($id <= self::DEFAULTCATEGORYID) {
			return false;
		}
		$sql = 'DELETE FROM `_category` WHERE id=:id';
		$stm = $this->pdo->prepare($sql);
		if ($stm !== false && $stm->bindParam(':id', $id, PDO::PARAM_INT) && $stm->execute()) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/** @return Traversable<array{'id':int,'name':string,'kind':int,'lastUpdate':int,'error':int,'attributes'?:array<string,mixed>}> */
	public function selectAll(): Traversable {
		$sql = 'SELECT id, name, kind, `lastUpdate`, error, attributes FROM `_category`';
		$stm = $this->pdo->query($sql);
		if ($stm !== false) {
			while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
				/** @var array{'id':int,'name':string,'kind':int,'lastUpdate':int,'error':int,'attributes'?:array<string,mixed>} $row */
				yield $row;
			}
		} else {
			$info = $this->pdo->errorInfo();
			if ($this->autoUpdateDb($info)) {
				yield from $this->selectAll();
			} else {
				Minz_Log::error(__method__ . ' error: ' . json_encode($info));
			}
		}
	}

	public function searchById(int $id): ?FreshRSS_Category {
		$sql = 'SELECT * FROM `_category` WHERE id=:id';
		$res = $this->fetchAssoc($sql, ['id' => $id]) ?? [];
		/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate':int,'error':int|bool,'attributes':string}> $res */
		$categories = self::daoToCategories($res);
		return reset($categories) ?: null;
	}

	public function searchByName(string $name): ?FreshRSS_Category {
		$sql = 'SELECT * FROM `_category` WHERE name=:name';
		$res = $this->fetchAssoc($sql, ['name' => $name]) ?? [];
		/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate':int,'error':int|bool,'attributes':string}> $res */
		$categories = self::daoToCategories($res);
		return reset($categories) ?: null;
	}

	/** @return array<int,FreshRSS_Category> */
	public function listSortedCategories(bool $prePopulateFeeds = true, bool $details = false): array {
		$categories = $this->listCategories($prePopulateFeeds, $details);

		uasort($categories, static function (FreshRSS_Category $a, FreshRSS_Category $b) {
			$aPosition = $a->attributeInt('position');
			$bPosition = $b->attributeInt('position');
			if ($aPosition === $bPosition) {
				return ($a->name() < $b->name()) ? -1 : 1;
			} elseif (null === $aPosition) {
				return 1;
			} elseif (null === $bPosition) {
				return -1;
			}
			return ($aPosition < $bPosition) ? -1 : 1;
		});

		return $categories;
	}

	/** @return array<int,FreshRSS_Category> */
	public function listCategories(bool $prePopulateFeeds = true, bool $details = false): array {
		if ($prePopulateFeeds) {
			$sql = 'SELECT c.id AS c_id, c.name AS c_name, c.kind AS c_kind, c.`lastUpdate` AS c_last_update, c.error AS c_error, c.attributes AS c_attributes, '
				. ($details ? 'f.* ' : 'f.id, f.name, f.url, f.kind, f.website, f.priority, f.error, f.attributes, f.`cache_nbEntries`, f.`cache_nbUnreads`, f.ttl ')
				. 'FROM `_category` c '
				. 'LEFT OUTER JOIN `_feed` f ON f.category=c.id '
				. 'WHERE f.priority >= :priority '
				. 'GROUP BY f.id, c_id '
				. 'ORDER BY c.name, f.name';
			$stm = $this->pdo->prepare($sql);
			$values = [ ':priority' => FreshRSS_Feed::PRIORITY_CATEGORY ];
			if ($stm !== false && $stm->execute($values)) {
				$res = $stm->fetchAll(PDO::FETCH_ASSOC) ?: [];
				/** @var array<array{'c_name':string,'c_id':int,'c_kind':int,'c_last_update':int,'c_error':int|bool,'c_attributes'?:string,
				 * 	'id'?:int,'name'?:string,'url'?:string,'kind'?:int,'category'?:int,'website'?:string,'priority'?:int,'error'?:int|bool,'attributes'?:string,'cache_nbEntries'?:int,'cache_nbUnreads'?:int,'ttl'?:int}> $res */
				return self::daoToCategoriesPrepopulated($res);
			} else {
				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
				if ($this->autoUpdateDb($info)) {
					return $this->listCategories($prePopulateFeeds, $details);
				}
				Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
				return [];
			}
		} else {
			$res = $this->fetchAssoc('SELECT * FROM `_category` ORDER BY name');
			/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string}> $res */
			return empty($res) ? [] : self::daoToCategories($res);
		}
	}

	/** @return array<int,FreshRSS_Category> */
	public function listCategoriesOrderUpdate(int $defaultCacheDuration = 86400, int $limit = 0): array {
		$sql = 'SELECT * FROM `_category` WHERE kind = :kind AND `lastUpdate` < :lu ORDER BY `lastUpdate`'
			. ($limit < 1 ? '' : ' LIMIT ' . $limit);
		$stm = $this->pdo->prepare($sql);
		if ($stm !== false &&
			$stm->bindValue(':kind', FreshRSS_Category::KIND_DYNAMIC_OPML, PDO::PARAM_INT) &&
			$stm->bindValue(':lu', time() - $defaultCacheDuration, PDO::PARAM_INT) &&
			$stm->execute()) {
			return self::daoToCategories($stm->fetchAll(PDO::FETCH_ASSOC));
		} else {
			$info = $stm ? $stm->errorInfo() : $this->pdo->errorInfo();
			if ($this->autoUpdateDb($info)) {
				return $this->listCategoriesOrderUpdate($defaultCacheDuration, $limit);
			}
			Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
			return [];
		}
	}

	public function getDefault(): ?FreshRSS_Category {
		$sql = 'SELECT * FROM `_category` WHERE id=:id';
		$res = $this->fetchAssoc($sql, [':id' => self::DEFAULTCATEGORYID]) ?? [];
		/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string}> $res */
		$categories = self::daoToCategories($res);
		if (isset($categories[self::DEFAULTCATEGORYID])) {
			return $categories[self::DEFAULTCATEGORYID];
		} else {
			if (FreshRSS_Context::$isCli) {
				fwrite(STDERR, 'FreshRSS database error: Default category not found!' . "\n");
			}
			Minz_Log::error('FreshRSS database error: Default category not found!');
			return null;
		}
	}

	/** @return int|bool */
	public function checkDefault() {
		$def_cat = $this->searchById(self::DEFAULTCATEGORYID);

		if ($def_cat == null) {
			$cat = new FreshRSS_Category(_t('gen.short.default_category'), self::DEFAULTCATEGORYID);

			$sql = 'INSERT INTO `_category`(id, name) VALUES(?, ?)';
			if ($this->pdo->dbType() === 'pgsql') {
				//Force call to nextval()
				$sql .= " RETURNING nextval('`_category_id_seq`');";
			}
			$stm = $this->pdo->prepare($sql);

			$values = [
				$cat->id(),
				$cat->name(),
			];

			if ($stm !== false && $stm->execute($values)) {
				$catId = $this->pdo->lastInsertId('`_category_id_seq`');
				return $catId === false ? false : (int)$catId;
			} else {
				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
				Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
				return false;
			}
		}
		return true;
	}

	public function count(): int {
		$sql = 'SELECT COUNT(*) AS count FROM `_category`';
		$res = $this->fetchColumn($sql, 0);
		return isset($res[0]) ? (int)$res[0] : -1;
	}

	public function countFeed(int $id): int {
		$sql = 'SELECT COUNT(*) AS count FROM `_feed` WHERE category=:id';
		$res = $this->fetchColumn($sql, 0, [':id' => $id]);
		return isset($res[0]) ? (int)$res[0] : -1;
	}

	public function countNotRead(int $id): int {
		$sql = 'SELECT COUNT(*) AS count FROM `_entry` e INNER JOIN `_feed` f ON e.id_feed=f.id WHERE category=:id AND e.is_read=0';
		$res = $this->fetchColumn($sql, 0, [':id' => $id]);
		return isset($res[0]) ? (int)$res[0] : -1;
	}

	/**
	 * @param array<array{'c_name':string,'c_id':int,'c_kind':int,'c_last_update':int,'c_error':int|bool,'c_attributes'?:string,
	 * 	'id'?:int,'name'?:string,'url'?:string,'kind'?:int,'website'?:string,'priority'?:int,
	 * 	'error'?:int|bool,'attributes'?:string,'cache_nbEntries'?:int,'cache_nbUnreads'?:int,'ttl'?:int}> $listDAO
	 * @return array<int,FreshRSS_Category>
	 */
	private static function daoToCategoriesPrepopulated(array $listDAO): array {
		$list = [];
		$previousLine = [];
		$feedsDao = [];
		$feedDao = FreshRSS_Factory::createFeedDao();
		foreach ($listDAO as $line) {
			FreshRSS_DatabaseDAO::pdoInt($line, ['c_id', 'c_kind', 'c_last_update', 'c_error',
				'id', 'kind', 'priority', 'error', 'cache_nbEntries', 'cache_nbUnreads', 'ttl']);
			if (!empty($previousLine['c_id']) && $line['c_id'] !== $previousLine['c_id']) {
				// End of the current category, we add it to the $list
				$cat = new FreshRSS_Category(
					$previousLine['c_name'],
					$previousLine['c_id'],
					$feedDao::daoToFeeds($feedsDao, $previousLine['c_id'])
				);
				$cat->_kind($previousLine['c_kind']);
				$cat->_attributes($previousLine['c_attributes'] ?? '[]');
				$list[$cat->id()] = $cat;

				$feedsDao = [];	//Prepare for next category
			}

			$previousLine = $line;
			$feedsDao[] = $line;
		}

		// add the last category
		if ($previousLine != null) {
			$cat = new FreshRSS_Category(
				$previousLine['c_name'],
				$previousLine['c_id'],
				$feedDao::daoToFeeds($feedsDao, $previousLine['c_id'])
			);
			$cat->_kind($previousLine['c_kind']);
			$cat->_lastUpdate($previousLine['c_last_update'] ?? 0);
			$cat->_error($previousLine['c_error'] ?? 0);
			$cat->_attributes($previousLine['c_attributes'] ?? []);
			$list[$cat->id()] = $cat;
		}

		return $list;
	}

	/**
	 * @param array<array{'name':string,'id':int,'kind':int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string}> $listDAO
	 * @return array<int,FreshRSS_Category>
	 */
	private static function daoToCategories(array $listDAO): array {
		$list = [];
		foreach ($listDAO as $dao) {
			FreshRSS_DatabaseDAO::pdoInt($dao, ['id', 'kind', 'lastUpdate', 'error']);
			$cat = new FreshRSS_Category(
				$dao['name'],
				$dao['id']
			);
			$cat->_kind($dao['kind']);
			$cat->_lastUpdate($dao['lastUpdate'] ?? 0);
			$cat->_error($dao['error'] ?? 0);
			$cat->_attributes($dao['attributes'] ?? '');
			$list[$cat->id()] = $cat;
		}
		return $list;
	}
}
CategoryDAOSQLite.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/CategoryDAOSQLite.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_CategoryDAOSQLite extends FreshRSS_CategoryDAO {

	/** @param array<int|string> $errorInfo */
	#[\Override]
	protected function autoUpdateDb(array $errorInfo): bool {
		if ($tableInfo = $this->pdo->query("PRAGMA table_info('category')")) {
			$columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1);
			foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) {
				if (!in_array($column, $columns, true)) {
					return $this->addColumn($column);
				}
			}
		}
		return false;
	}
}
Context.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Context.php'
View Content
<?php
declare(strict_types=1);

/**
 * The context object handles the current configuration file and different
 * useful functions associated to the current view state.
 */
final class FreshRSS_Context {

	/**
	 * @var array<int,FreshRSS_Category>
	 */
	private static array $categories = [];
	/**
	 * @var array<int,FreshRSS_Tag>
	 */
	private static array $tags = [];
	public static string $name = '';
	public static string $description = '';
	public static int $total_unread = 0;
	public static int $total_important_unread = 0;

	/** @var array{'all':int,'read':int,'unread':int} */
	public static array $total_starred = [
		'all' => 0,
		'read' => 0,
		'unread' => 0,
	];

	public static int $get_unread = 0;

	/** @var array{'all':bool,'starred':bool,'important':bool,'feed':int|false,'category':int|false,'tag':int|false,'tags':bool} */
	public static array $current_get = [
		'all' => false,
		'starred' => false,
		'important' => false,
		'feed' => false,
		'category' => false,
		'tag' => false,
		'tags' => false,
	];

	public static string $next_get = 'a';
	public static int $state = 0;
	/**
	 * @phpstan-var 'ASC'|'DESC'
	 */
	public static string $order = 'DESC';
	public static int $number = 0;
	public static int $offset = 0;
	public static FreshRSS_BooleanSearch $search;
	public static string $first_id = '';
	public static string $next_id = '';
	public static string $id_max = '';
	public static int $sinceHours = 0;
	public static bool $isCli = false;

	/**
	 * @deprecated Will be made `private`; use `FreshRSS_Context::systemConf()` instead.
	 * @internal
	 */
	public static ?FreshRSS_SystemConfiguration $system_conf = null;
	/**
	 * @deprecated Will be made `private`; use `FreshRSS_Context::userConf()` instead.
	 * @internal
	 */
	public static ?FreshRSS_UserConfiguration $user_conf = null;

	/**
	 * Initialize the context for the global system.
	 */
	public static function initSystem(bool $reload = false): void {
		if ($reload || FreshRSS_Context::$system_conf === null) {
			//TODO: Keep in session what we need instead of always reloading from disk
			FreshRSS_Context::$system_conf = FreshRSS_SystemConfiguration::init(DATA_PATH . '/config.php', FRESHRSS_PATH . '/config.default.php');
		}
	}

	/**
	 * @throws FreshRSS_Context_Exception
	 */
	public static function &systemConf(): FreshRSS_SystemConfiguration {
		if (FreshRSS_Context::$system_conf === null) {
			throw new FreshRSS_Context_Exception('System configuration not initialised!');
		}
		return FreshRSS_Context::$system_conf;
	}

	public static function hasSystemConf(): bool {
		return FreshRSS_Context::$system_conf !== null;
	}

	/**
	 * Initialize the context for the current user.
	 */
	public static function initUser(string $username = '', bool $userMustExist = true): void {
		FreshRSS_Context::$user_conf = null;
		if (!isset($_SESSION)) {
			Minz_Session::init('FreshRSS');
		}

		Minz_Session::lock();
		if ($username == '') {
			$username = Minz_User::name() ?? '';
		}
		if (($username === Minz_User::INTERNAL_USER || FreshRSS_user_Controller::checkUsername($username)) &&
			(!$userMustExist || FreshRSS_user_Controller::userExists($username))) {
			try {
				//TODO: Keep in session what we need instead of always reloading from disk
				FreshRSS_Context::$user_conf = FreshRSS_UserConfiguration::init(
					USERS_PATH . '/' . $username . '/config.php',
					FRESHRSS_PATH . '/config-user.default.php');

				Minz_User::change($username);
			} catch (Exception $ex) {
				Minz_Log::warning($ex->getMessage(), USERS_PATH . '/_/' . LOG_FILENAME);
			}
		}
		if (FreshRSS_Context::$user_conf == null) {
			Minz_Session::_params([
				'loginOk' => false,
				Minz_User::CURRENT_USER => false,
			]);
		}
		Minz_Session::unlock();

		if (FreshRSS_Context::$user_conf == null) {
			return;
		}

		FreshRSS_Context::$search = new FreshRSS_BooleanSearch('');

		//Legacy
		$oldEntries = FreshRSS_Context::$user_conf->param('old_entries', 0);
		$oldEntries = is_numeric($oldEntries) ? (int)$oldEntries : 0;
		$keepMin = FreshRSS_Context::$user_conf->param('keep_history_default', -5);
		$keepMin = is_numeric($keepMin) ? (int)$keepMin : -5;
		if ($oldEntries > 0 || $keepMin > -5) {	//Freshrss < 1.15
			$archiving = FreshRSS_Context::$user_conf->archiving;
			$archiving['keep_max'] = false;
			if ($oldEntries > 0) {
				$archiving['keep_period'] = 'P' . $oldEntries . 'M';
			}
			if ($keepMin > 0) {
				$archiving['keep_min'] = $keepMin;
			} elseif ($keepMin == -1) {	//Infinite
				$archiving['keep_period'] = false;
				$archiving['keep_min'] = false;
			}
			FreshRSS_Context::$user_conf->archiving = $archiving;
		}

		//Legacy < 1.16.1
		if (!in_array(FreshRSS_Context::$user_conf->display_categories, [ 'active', 'remember', 'all', 'none' ], true)) {
			FreshRSS_Context::$user_conf->display_categories = FreshRSS_Context::$user_conf->display_categories === true ? 'all' : 'active';
		}
	}

	/**
	 * @throws FreshRSS_Context_Exception
	 */
	public static function &userConf(): FreshRSS_UserConfiguration {
		if (FreshRSS_Context::$user_conf === null) {
			throw new FreshRSS_Context_Exception('User configuration not initialised!');
		}
		return FreshRSS_Context::$user_conf;
	}

	public static function hasUserConf(): bool {
		return FreshRSS_Context::$user_conf !== null;
	}

	public static function clearUserConf(): void {
		FreshRSS_Context::$user_conf = null;
	}

	/** @return array<int,FreshRSS_Category> */
	public static function categories(): array {
		if (empty(self::$categories)) {
			$catDAO = FreshRSS_Factory::createCategoryDao();
			self::$categories = $catDAO->listSortedCategories(true, false);
		}
		return self::$categories;
	}

	/** @return array<int,FreshRSS_Feed> */
	public static function feeds(): array {
		return FreshRSS_Category::findFeeds(self::categories());
	}

	/** @return array<int,FreshRSS_Tag> */
	public static function labels(bool $precounts = false): array {
		if (empty(self::$tags) || $precounts) {
			$tagDAO = FreshRSS_Factory::createTagDao();
			self::$tags = $tagDAO->listTags($precounts) ?: [];
		}
		return self::$tags;
	}

	/**
	 * This action updates the Context object by using request parameters.
	 *
	 * HTTP GET request parameters are:
	 *   - state (default: conf->default_view)
	 *   - search (default: empty string)
	 *   - order (default: conf->sort_order)
	 *   - nb (default: conf->posts_per_page)
	 *   - next (default: empty string)
	 *   - hours (default: 0)
	 * @throws FreshRSS_Context_Exception
	 * @throws Minz_ConfigurationNamespaceException
	 * @throws Minz_PDOConnectionException
	 */
	public static function updateUsingRequest(bool $computeStatistics): void {
		if ($computeStatistics && self::$total_unread === 0) {
			// Update number of read / unread variables.
			$entryDAO = FreshRSS_Factory::createEntryDao();
			self::$total_starred = $entryDAO->countUnreadReadFavorites();
			self::$total_unread = FreshRSS_Category::countUnread(self::categories(), FreshRSS_Feed::PRIORITY_MAIN_STREAM);
			self::$total_important_unread = FreshRSS_Category::countUnread(self::categories(), FreshRSS_Feed::PRIORITY_IMPORTANT);
		}

		self::_get(Minz_Request::paramString('get') ?: 'a');

		self::$state = Minz_Request::paramInt('state') ?: FreshRSS_Context::userConf()->default_state;
		$state_forced_by_user = Minz_Request::paramString('state') !== '';
		if (!$state_forced_by_user && !self::isStateEnabled(FreshRSS_Entry::STATE_READ)) {
			if (FreshRSS_Context::userConf()->default_view === 'all') {
				self::$state |= FreshRSS_Entry::STATE_ALL;
			} elseif (FreshRSS_Context::userConf()->default_view === 'adaptive' && self::$get_unread <= 0) {
				self::$state |= FreshRSS_Entry::STATE_READ;
			}
			if (FreshRSS_Context::userConf()->show_fav_unread &&
					(self::isCurrentGet('s') || self::isCurrentGet('T') || self::isTag())) {
				self::$state |= FreshRSS_Entry::STATE_READ;
			}
		}

		self::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search'));
		$order = Minz_Request::paramString('order') ?: FreshRSS_Context::userConf()->sort_order;
		self::$order = in_array($order, ['ASC', 'DESC'], true) ? $order : 'DESC';
		self::$number = Minz_Request::paramInt('nb') ?: FreshRSS_Context::userConf()->posts_per_page;
		if (self::$number > FreshRSS_Context::userConf()->max_posts_per_rss) {
			self::$number = max(
				FreshRSS_Context::userConf()->max_posts_per_rss,
				FreshRSS_Context::userConf()->posts_per_page);
		}
		self::$offset = Minz_Request::paramInt('offset');
		self::$first_id = Minz_Request::paramString('next');
		self::$sinceHours = Minz_Request::paramInt('hours');
	}

	/**
	 * Returns if the current state includes $state parameter.
	 */
	public static function isStateEnabled(int $state): int {
		return self::$state & $state;
	}

	/**
	 * Returns the current state with or without $state parameter.
	 */
	public static function getRevertState(int $state): int {
		if (self::$state & $state) {
			return self::$state & ~$state;
		}
		return self::$state | $state;
	}

	/**
	 * Return the current get as a string or an array.
	 *
	 * If $array is true, the first item of the returned value is 'f' or 'c' or 't' and the second is the id.
	 * @phpstan-return ($asArray is true ? array{'a'|'c'|'f'|'i'|'s'|'t'|'T',bool|int} : string)
	 * @return string|array{string,bool|int}
	 */
	public static function currentGet(bool $asArray = false) {
		if (self::$current_get['all']) {
			return $asArray ? ['a', true] : 'a';
		} elseif (self::$current_get['important']) {
			return $asArray ? ['i', true] : 'i';
		} elseif (self::$current_get['starred']) {
			return $asArray ? ['s', true] : 's';
		} elseif (self::$current_get['feed']) {
			if ($asArray) {
				return ['f', self::$current_get['feed']];
			} else {
				return 'f_' . self::$current_get['feed'];
			}
		} elseif (self::$current_get['category']) {
			if ($asArray) {
				return ['c', self::$current_get['category']];
			} else {
				return 'c_' . self::$current_get['category'];
			}
		} elseif (self::$current_get['tag']) {
			if ($asArray) {
				return ['t', self::$current_get['tag']];
			} else {
				return 't_' . self::$current_get['tag'];
			}
		} elseif (self::$current_get['tags']) {
			return $asArray ? ['T', true] : 'T';
		}
		return '';
	}

	/**
	 * @return bool true if the current request targets all feeds (main view), false otherwise.
	 */
	public static function isAll(): bool {
		return self::$current_get['all'] != false;
	}

	/**
	 * @return bool true if the current request targets important feeds, false otherwise.
	 */
	public static function isImportant(): bool {
		return self::$current_get['important'] != false;
	}

	/**
	 * @return bool true if the current request targets a category, false otherwise.
	 */
	public static function isCategory(): bool {
		return self::$current_get['category'] != false;
	}

	/**
	 * @return bool true if the current request targets a feed (and not a category or all articles), false otherwise.
	 */
	public static function isFeed(): bool {
		return self::$current_get['feed'] != false;
	}

	/**
	 * @return bool true if the current request targets a tag (though not all tags), false otherwise.
	 */
	public static function isTag(): bool {
		return self::$current_get['tag'] != false;
	}

	/**
	 * @return bool whether $get parameter corresponds to the $current_get attribute.
	 */
	public static function isCurrentGet(string $get): bool {
		$type = substr($get, 0, 1);
		$id = substr($get, 2);

		switch ($type) {
			case 'a':
				return self::$current_get['all'];
			case 'i':
				return self::$current_get['important'];
			case 's':
				return self::$current_get['starred'];
			case 'f':
				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;
		}
	}

	/**
	 * Set the current $get attribute.
	 *
	 * Valid $get parameter are:
	 *   - a
	 *   - 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.
	 * @throws FreshRSS_Context_Exception
	 * @throws Minz_ConfigurationNamespaceException
	 * @throws Minz_PDOConnectionException
	 */
	public static function _get(string $get): void {
		$type = $get[0];
		$id = (int)substr($get, 2);

		if (empty(self::$categories)) {
			$catDAO = FreshRSS_Factory::createCategoryDao();
			$details = $type === 'f'; 	// Load additional feed details in the case of feed view
			self::$categories = $catDAO->listCategories(true, $details);
		}

		switch ($type) {
			case 'a':
				self::$current_get['all'] = true;
				self::$name = _t('index.feed.title');
				self::$description = FreshRSS_Context::systemConf()->meta_description;
				self::$get_unread = self::$total_unread;
				break;
			case 'i':
				self::$current_get['important'] = true;
				self::$name = _t('index.menu.important');
				self::$description = FreshRSS_Context::systemConf()->meta_description;
				self::$get_unread = self::$total_unread;
				break;
			case 's':
				self::$current_get['starred'] = true;
				self::$name = _t('index.feed.title_fav');
				self::$description = FreshRSS_Context::systemConf()->meta_description;
				self::$get_unread = self::$total_starred['unread'];

				// Update state if favorite is not yet enabled.
				self::$state = self::$state | FreshRSS_Entry::STATE_FAVORITE;
				break;
			case 'f':
				// We try to find the corresponding feed. When allowing robots, always retrieve the full feed including description
				$feed = FreshRSS_Context::systemConf()->allow_robots ? null : FreshRSS_Category::findFeed(self::$categories, $id);
				if ($feed === null) {
					$feedDAO = FreshRSS_Factory::createFeedDao();
					$feed = $feedDAO->searchById($id);
					if ($feed === null) {
						throw new FreshRSS_Context_Exception('Invalid feed: ' . $id);
					}
				}
				self::$current_get['feed'] = $id;
				self::$current_get['category'] = $feed->categoryId();
				self::$name = $feed->name();
				self::$description = $feed->description();
				self::$get_unread = $feed->nbNotRead();
				break;
			case 'c':
				// We try to find the corresponding category.
				self::$current_get['category'] = $id;
				if (!isset(self::$categories[$id])) {
					$catDAO = FreshRSS_Factory::createCategoryDao();
					$cat = $catDAO->searchById($id);
					if ($cat === null) {
						throw new FreshRSS_Context_Exception('Invalid category: ' . $id);
					}
					self::$categories[$id] = $cat;
				} 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 === null) {
						throw new FreshRSS_Context_Exception('Invalid tag: ' . $id);
					}
					self::$tags[$id] = $tag;
				} else {
					$tag = self::$tags[$id];
				}
				self::$name = $tag->name();
				self::$get_unread = $tag->nbUnread();
				break;
			case 'T':
				$tagDAO = FreshRSS_Factory::createTagDao();
				self::$current_get['tags'] = true;
				self::$name = _t('index.menu.tags');
				self::$get_unread = $tagDAO->countNotRead();
				break;
			default:
				throw new FreshRSS_Context_Exception('Invalid getter: ' . $get);
		}

		self::_nextGet();
	}

	/**
	 * Set the value of $next_get attribute.
	 */
	private static function _nextGet(): void {
		$get = self::currentGet();
		// By default, $next_get == $get
		self::$next_get = $get;

		if (empty(self::$categories)) {
			$catDAO = FreshRSS_Factory::createCategoryDao();
			self::$categories = $catDAO->listCategories(true);
		}

		if (FreshRSS_Context::userConf()->onread_jump_next && strlen($get) > 2) {
			$another_unread_id = '';
			$found_current_get = false;
			switch ($get[0]) {
				case 'f':
					// We search the next unread feed with the following priorities: next in same category, or previous in same category, or next, or previous.
					foreach (self::$categories as $cat) {
						$sameCat = false;
						foreach ($cat->feeds() as $feed) {
							if ($found_current_get) {
								if ($feed->nbNotRead() > 0) {
									$another_unread_id = $feed->id();
									break 2;
								}
							} elseif ($feed->id() == self::$current_get['feed']) {
								$found_current_get = true;
							} elseif ($feed->nbNotRead() > 0) {
								$another_unread_id = $feed->id();
								$sameCat = true;
							}
						}
						if ($found_current_get && $sameCat) {
							break;
						}
					}

					// If there is no more unread feed, show main stream
					self::$next_get = $another_unread_id == '' ? 'a' : 'f_' . $another_unread_id;
					break;
				case 'c':
					// We search the next category with at least one unread article.
					foreach (self::$categories as $cat) {
						if ($cat->id() == self::$current_get['category']) {
							// Here is our current category! Next one could be our
							// champion if it has unread articles.
							$found_current_get = true;
							continue;
						}

						if ($cat->nbNotRead() > 0) {
							$another_unread_id = $cat->id();
							if ($found_current_get) {
								// Unread articles and the current category has
								// already been found? Leave the loop!
								break;
							}
						}
					}

					// If there is no more unread category, show main stream
					self::$next_get = $another_unread_id == '' ? 'a' : 'c_' . $another_unread_id;
					break;
			}
		}
	}

	/**
	 * Determine if the auto remove is available in the current context.
	 * This feature is available if:
	 *   - it is activated in the configuration
	 *   - the "read" state is not enable
	 *   - the "unread" state is enable
	 */
	public static function isAutoRemoveAvailable(): bool {
		if (!FreshRSS_Context::userConf()->auto_remove_article) {
			return false;
		}
		if (self::isStateEnabled(FreshRSS_Entry::STATE_READ)) {
			return false;
		}
		if (!self::isStateEnabled(FreshRSS_Entry::STATE_NOT_READ)) {
			return false;
		}
		return true;
	}

	/**
	 * Determine if the "sticky post" option is enabled. It can be enable
	 * by the user when it is selected in the configuration page or by the
	 * application when the context allows to auto-remove articles when they
	 * are read.
	 */
	public static function isStickyPostEnabled(): bool {
		if (FreshRSS_Context::userConf()->sticky_post) {
			return true;
		}
		if (self::isAutoRemoveAvailable()) {
			return true;
		}
		return false;
	}

	public static function defaultTimeZone(): string {
		$timezone = ini_get('date.timezone');
		return $timezone != false ? $timezone : 'UTC';
	}
}
DatabaseDAO.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/DatabaseDAO.php'
View Content
<?php
declare(strict_types=1);

/**
 * This class is used to test database is well-constructed.
 */
class FreshRSS_DatabaseDAO extends Minz_ModelPdo {

	//MySQL error codes
	public const ER_BAD_FIELD_ERROR = '42S22';
	public const ER_BAD_TABLE_ERROR = '42S02';
	public const ER_DATA_TOO_LONG = '1406';

	/**
	 * Based on SQLite SQLITE_MAX_VARIABLE_NUMBER
	 */
	public const MAX_VARIABLE_NUMBER = 998;

	//MySQL InnoDB maximum index length for UTF8MB4
	//https://dev.mysql.com/doc/refman/8.0/en/innodb-restrictions.html
	public const LENGTH_INDEX_UNICODE = 191;

	public function create(): string {
		require_once(APP_PATH . '/SQL/install.sql.' . $this->pdo->dbType() . '.php');
		$db = FreshRSS_Context::systemConf()->db;

		try {
			$sql = sprintf($GLOBALS['SQL_CREATE_DB'], empty($db['base']) ? '' : $db['base']);
			return $this->pdo->exec($sql) === false ? 'Error during CREATE DATABASE' : '';
		} catch (Exception $e) {
			syslog(LOG_DEBUG, __method__ . ' notice: ' . $e->getMessage());
			return $e->getMessage();
		}
	}

	public function testConnection(): string {
		try {
			$sql = 'SELECT 1';
			$stm = $this->pdo->query($sql);
			if ($stm === false) {
				return 'Error during SQL connection test!';
			}
			$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
			return $res == false ? 'Error during SQL connection fetch test!' : '';
		} catch (Exception $e) {
			syslog(LOG_DEBUG, __method__ . ' warning: ' . $e->getMessage());
			return $e->getMessage();
		}
	}

	public function exits(): bool {
		$sql = 'SELECT * FROM `_entry` LIMIT 1';
		$stm = $this->pdo->query($sql);
		if ($stm !== false) {
			$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
			if ($res !== false) {
				return true;
			}
		}
		return false;
	}

	public function tablesAreCorrect(): bool {
		$res = $this->fetchAssoc('SHOW TABLES');
		if ($res == null) {
			return false;
		}

		$tables = [
			$this->pdo->prefix() . 'category' => false,
			$this->pdo->prefix() . 'feed' => false,
			$this->pdo->prefix() . 'entry' => false,
			$this->pdo->prefix() . 'entrytmp' => false,
			$this->pdo->prefix() . 'tag' => false,
			$this->pdo->prefix() . 'entrytag' => false,
		];
		foreach ($res as $value) {
			$tables[array_pop($value)] = true;
		}

		return count(array_keys($tables, true, true)) === count($tables);
	}

	/** @return array<array{name:string,type:string,notnull:bool,default:mixed}> */
	public function getSchema(string $table): array {
		$res = $this->fetchAssoc('DESC `_' . $table . '`');
		return $res == null ? [] : $this->listDaoToSchema($res);
	}

	/** @param array<string> $schema */
	public function checkTable(string $table, array $schema): bool {
		$columns = $this->getSchema($table);
		if (count($columns) === 0 || count($schema) === 0) {
			return false;
		}

		$ok = count($columns) === count($schema);
		foreach ($columns as $c) {
			$ok &= in_array($c['name'], $schema, true);
		}

		return (bool)$ok;
	}

	public function categoryIsCorrect(): bool {
		return $this->checkTable('category', ['id', 'name']);
	}

	public function feedIsCorrect(): bool {
		return $this->checkTable('feed', [
			'id',
			'url',
			'category',
			'name',
			'website',
			'description',
			'lastUpdate',
			'priority',
			'pathEntries',
			'httpAuth',
			'error',
			'ttl',
			'attributes',
			'cache_nbEntries',
			'cache_nbUnreads',
		]);
	}

	public function entryIsCorrect(): bool {
		return $this->checkTable('entry', [
			'id',
			'guid',
			'title',
			'author',
			'content_bin',
			'link',
			'date',
			'lastSeen',
			'hash',
			'is_read',
			'is_favorite',
			'id_feed',
			'tags',
		]);
	}

	public function entrytmpIsCorrect(): bool {
		return $this->checkTable('entrytmp', [
			'id', 'guid', 'title', 'author', 'content_bin', 'link', 'date', 'lastSeen', 'hash', 'is_read', 'is_favorite', 'id_feed', 'tags'
		]);
	}

	public function tagIsCorrect(): bool {
		return $this->checkTable('tag', ['id', 'name', 'attributes']);
	}

	public function entrytagIsCorrect(): bool {
		return $this->checkTable('entrytag', ['id_tag', 'id_entry']);
	}

	/**
	 * @param array<string,string|int|bool|null> $dao
	 * @return array{name:string,type:string,notnull:bool,default:mixed}
	 */
	public function daoToSchema(array $dao): array {
		return [
			'name' => (string)($dao['Field']),
			'type' => strtolower((string)($dao['Type'])),
			'notnull' => (bool)$dao['Null'],
			'default' => $dao['Default'],
		];
	}

	/**
	 * @param array<array<string,string|int|bool|null>> $listDAO
	 * @return array<array{name:string,type:string,notnull:bool,default:mixed}>
	 */
	public function listDaoToSchema(array $listDAO): array {
		$list = [];

		foreach ($listDAO as $dao) {
			$list[] = $this->daoToSchema($dao);
		}

		return $list;
	}

	public function size(bool $all = false): int {
		$db = FreshRSS_Context::systemConf()->db;

		// MariaDB does not refresh size information automatically
		$sql = <<<'SQL'
ANALYZE TABLE `_category`, `_feed`, `_entry`, `_entrytmp`, `_tag`, `_entrytag`
SQL;
		$stm = $this->pdo->query($sql);
		if ($stm !== false) {
			$stm->fetchAll();
		}

		//MySQL:
		$sql = <<<'SQL'
SELECT SUM(DATA_LENGTH + INDEX_LENGTH + DATA_FREE)
FROM information_schema.TABLES WHERE TABLE_SCHEMA=:table_schema
SQL;
		$values = [':table_schema' => $db['base']];
		if (!$all) {
			$sql .= ' AND table_name LIKE :table_name';
			$values[':table_name'] = $this->pdo->prefix() . '%';
		}
		$res = $this->fetchColumn($sql, 0, $values);
		return isset($res[0]) ? (int)($res[0]) : -1;
	}

	public function optimize(): bool {
		$ok = true;
		$tables = ['category', 'feed', 'entry', 'entrytmp', 'tag', 'entrytag'];

		foreach ($tables as $table) {
			$sql = 'OPTIMIZE TABLE `_' . $table . '`';	//MySQL
			$stm = $this->pdo->query($sql);
			if ($stm == false || $stm->fetchAll(PDO::FETCH_ASSOC) == false) {
				$ok = false;
				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
				Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
			}
		}
		return $ok;
	}

	public function minorDbMaintenance(): void {
		$catDAO = FreshRSS_Factory::createCategoryDao();
		$catDAO->resetDefaultCategoryName();

		include_once(APP_PATH . '/SQL/install.sql.' . $this->pdo->dbType() . '.php');
		if (!empty($GLOBALS['SQL_UPDATE_MINOR'])) {
			$sql = $GLOBALS['SQL_UPDATE_MINOR'];
			$isMariaDB = false;

			if ($this->pdo->dbType() === 'mysql') {
				$dbVersion = $this->fetchValue('SELECT version()') ?? '';
				$isMariaDB = stripos($dbVersion, 'MariaDB') !== false;	// MariaDB includes its name in version, but not MySQL
				if (!$isMariaDB) {
					// MySQL does not support `DROP INDEX IF EXISTS` yet https://dev.mysql.com/doc/refman/8.3/en/drop-index.html
					// but MariaDB does https://mariadb.com/kb/en/drop-index/
					$sql = str_replace('DROP INDEX IF EXISTS', 'DROP INDEX', $sql);
				}
			}

			if ($this->pdo->exec($sql) === false) {
				$info = $this->pdo->errorInfo();
				if ($this->pdo->dbType() === 'mysql' &&
					!$isMariaDB && !empty($info[2]) && (stripos($info[2], "Can't DROP ") !== false)) {
					// Too bad for MySQL, but ignore error
					return;
				}
				Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
			}
		}
	}

	private static function stdError(string $error): bool {
		if (defined('STDERR')) {
			fwrite(STDERR, $error . "\n");
		}
		Minz_Log::error($error);
		return false;
	}

	public const SQLITE_EXPORT = 1;
	public const SQLITE_IMPORT = 2;

	public function dbCopy(string $filename, int $mode, bool $clearFirst = false, bool $verbose = true): bool {
		if (!extension_loaded('pdo_sqlite')) {
			return self::stdError('PHP extension pdo_sqlite is missing!');
		}
		$error = '';

		$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
		$userDAO = FreshRSS_Factory::createUserDao();
		$catDAO = FreshRSS_Factory::createCategoryDao();
		$feedDAO = FreshRSS_Factory::createFeedDao();
		$entryDAO = FreshRSS_Factory::createEntryDao();
		$tagDAO = FreshRSS_Factory::createTagDao();

		switch ($mode) {
			case self::SQLITE_EXPORT:
				if (@filesize($filename) > 0) {
					$error = 'Error: SQLite export file already exists: ' . $filename;
				}
				break;
			case self::SQLITE_IMPORT:
				if (!is_readable($filename)) {
					$error = 'Error: SQLite import file is not readable: ' . $filename;
				} elseif ($clearFirst) {
					$userDAO->deleteUser();
					$userDAO = FreshRSS_Factory::createUserDao();
					if ($this->pdo->dbType() === 'sqlite') {
						//We cannot just delete the .sqlite file otherwise PDO gets buggy.
						//SQLite is the only one with database-level optimization, instead of at table level.
						$this->optimize();
					}
				} else {
					if ($databaseDAO->exits()) {
						$nbEntries = $entryDAO->countUnreadRead();
						if (isset($nbEntries['all']) && $nbEntries['all'] > 0) {
							$error = 'Error: Destination database already contains some entries!';
						}
					}
				}
				break;
			default:
				$error = 'Invalid copy mode!';
				break;
		}
		if ($error != '') {
			return self::stdError($error);
		}

		$sqlite = null;

		try {
			$sqlite = new Minz_PdoSqlite('sqlite:' . $filename);
			$sqlite->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT);
		} catch (Exception $e) {
			$error = 'Error while initialising SQLite copy: ' . $e->getMessage();
			return self::stdError($error);
		}

		Minz_ModelPdo::clean();
		$userDAOSQLite = new FreshRSS_UserDAO('', $sqlite);
		$categoryDAOSQLite = new FreshRSS_CategoryDAOSQLite('', $sqlite);
		$feedDAOSQLite = new FreshRSS_FeedDAOSQLite('', $sqlite);
		$entryDAOSQLite = new FreshRSS_EntryDAOSQLite('', $sqlite);
		$tagDAOSQLite = new FreshRSS_TagDAOSQLite('', $sqlite);

		switch ($mode) {
			case self::SQLITE_EXPORT:
				$userFrom = $userDAO; $userTo = $userDAOSQLite;
				$catFrom = $catDAO; $catTo = $categoryDAOSQLite;
				$feedFrom = $feedDAO; $feedTo = $feedDAOSQLite;
				$entryFrom = $entryDAO; $entryTo = $entryDAOSQLite;
				$tagFrom = $tagDAO; $tagTo = $tagDAOSQLite;
				break;
			case self::SQLITE_IMPORT:
				$userFrom = $userDAOSQLite; $userTo = $userDAO;
				$catFrom = $categoryDAOSQLite; $catTo = $catDAO;
				$feedFrom = $feedDAOSQLite; $feedTo = $feedDAO;
				$entryFrom = $entryDAOSQLite; $entryTo = $entryDAO;
				$tagFrom = $tagDAOSQLite; $tagTo = $tagDAO;
				break;
			default:
				return false;
		}

		$idMaps = [];

		if (defined('STDERR') && $verbose) {
			fwrite(STDERR, "Start SQL copy…\n");
		}

		$userTo->createUser();

		$catTo->beginTransaction();
		foreach ($catFrom->selectAll() as $category) {
			$cat = $catTo->searchByName($category['name']);	//Useful for the default category
			if ($cat != null) {
				$catId = $cat->id();
			} else {
				$catId = $catTo->addCategory($category);
				if ($catId == false) {
					$error = 'Error during SQLite copy of categories!';
					return self::stdError($error);
				}
			}
			$idMaps['c' . $category['id']] = $catId;
		}
		foreach ($feedFrom->selectAll() as $feed) {
			$feed['category'] = empty($idMaps['c' . $feed['category']]) ? FreshRSS_CategoryDAO::DEFAULTCATEGORYID : $idMaps['c' . $feed['category']];
			$feedId = $feedTo->addFeed($feed);
			if ($feedId == false) {
				$error = 'Error during SQLite copy of feeds!';
				return self::stdError($error);
			}
			$idMaps['f' . $feed['id']] = $feedId;
		}
		$catTo->commit();

		$nbEntries = $entryFrom->count();
		$n = 0;
		$entryTo->beginTransaction();
		foreach ($entryFrom->selectAll() as $entry) {
			$n++;
			if (!empty($idMaps['f' . $entry['id_feed']])) {
				$entry['id_feed'] = $idMaps['f' . $entry['id_feed']];
				if (!$entryTo->addEntry($entry, false)) {
					$error = 'Error during SQLite copy of entries!';
					return self::stdError($error);
				}
			}
			if ($n % 100 === 1 && defined('STDERR') && $verbose) {	//Display progression
				fwrite(STDERR, "\033[0G" . $n . '/' . $nbEntries);
			}
		}
		if (defined('STDERR') && $verbose) {
			fwrite(STDERR, "\033[0G" . $n . '/' . $nbEntries . "\n");
		}
		$entryTo->commit();
		$feedTo->updateCachedValues();

		$idMaps = [];

		$tagTo->beginTransaction();
		foreach ($tagFrom->selectAll() as $tag) {
			$tagId = $tagTo->addTag($tag);
			if ($tagId == false) {
				$error = 'Error during SQLite copy of tags!';
				return self::stdError($error);
			}
			$idMaps['t' . $tag['id']] = $tagId;
		}
		foreach ($tagFrom->selectEntryTag() as $entryTag) {
			if (!empty($idMaps['t' . $entryTag['id_tag']])) {
				$entryTag['id_tag'] = $idMaps['t' . $entryTag['id_tag']];
				if (!$tagTo->tagEntry($entryTag['id_tag'], $entryTag['id_entry'])) {
					$error = 'Error during SQLite copy of entry-tags!';
					return self::stdError($error);
				}
			}
		}
		$tagTo->commit();

		return true;
	}

	/**
	 * Ensure that some PDO columns are `int` and not `string`.
	 * Compatibility with PHP 7.
	 * @param array<string|int|null> $table
	 * @param array<string> $columns
	 */
	public static function pdoInt(array &$table, array $columns): void {
		foreach ($columns as $column) {
			if (isset($table[$column]) && is_string($table[$column])) {
				$table[$column] = (int)$table[$column];
			}
		}
	}

	/**
	 * Ensure that some PDO columns are `string` and not `bigint`.
	 * @param array<string|int|null> $table
	 * @param array<string> $columns
	 */
	public static function pdoString(array &$table, array $columns): void {
		foreach ($columns as $column) {
			if (isset($table[$column])) {
				$table[$column] = (string)$table[$column];
			}
		}
	}
}
DatabaseDAOPGSQL.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/DatabaseDAOPGSQL.php'
View Content
<?php
declare(strict_types=1);

/**
 * This class is used to test database is well-constructed.
 */
class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAOSQLite {

	//PostgreSQL error codes
	public const UNDEFINED_COLUMN = '42703';
	public const UNDEFINED_TABLE = '42P01';

	#[\Override]
	public function tablesAreCorrect(): bool {
		$db = FreshRSS_Context::systemConf()->db;
		$sql = 'SELECT * FROM pg_catalog.pg_tables where tableowner=:tableowner';
		$res = $this->fetchAssoc($sql, [':tableowner' => $db['user']]);
		if ($res == null) {
			return false;
		}

		$tables = [
			$this->pdo->prefix() . 'category' => false,
			$this->pdo->prefix() . 'feed' => false,
			$this->pdo->prefix() . 'entry' => false,
			$this->pdo->prefix() . 'entrytmp' => false,
			$this->pdo->prefix() . 'tag' => false,
			$this->pdo->prefix() . 'entrytag' => false,
		];
		foreach ($res as $value) {
			$tables[array_pop($value)] = true;
		}

		return count(array_keys($tables, true, true)) === count($tables);
	}

	/** @return array<array{name:string,type:string,notnull:bool,default:mixed}> */
	#[\Override]
	public function getSchema(string $table): array {
		$sql = <<<'SQL'
SELECT column_name AS field, data_type AS type, column_default AS default, is_nullable AS null
FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = :table_name
SQL;
		$res = $this->fetchAssoc($sql, [':table_name' => $this->pdo->prefix() . $table]);
		return $res == null ? [] : $this->listDaoToSchema($res);
	}

	/**
	 * @param array<string,string|int|bool|null> $dao
	 * @return array{'name':string,'type':string,'notnull':bool,'default':mixed}
	 */
	#[\Override]
	public function daoToSchema(array $dao): array {
		return [
			'name' => (string)($dao['field']),
			'type' => strtolower((string)($dao['type'])),
			'notnull' => (bool)$dao['null'],
			'default' => $dao['default'],
		];
	}

	#[\Override]
	public function size(bool $all = false): int {
		if ($all) {
			$db = FreshRSS_Context::systemConf()->db;
			$res = $this->fetchColumn('SELECT pg_database_size(:base)', 0, [':base' => $db['base']]);
		} else {
			$sql = <<<SQL
SELECT
pg_total_relation_size('`{$this->pdo->prefix()}category`') +
pg_total_relation_size('`{$this->pdo->prefix()}feed`') +
pg_total_relation_size('`{$this->pdo->prefix()}entry`') +
pg_total_relation_size('`{$this->pdo->prefix()}entrytmp`') +
pg_total_relation_size('`{$this->pdo->prefix()}tag`') +
pg_total_relation_size('`{$this->pdo->prefix()}entrytag`')
SQL;
			$res = $this->fetchColumn($sql, 0);
		}
		return (int)($res[0] ?? -1);
	}

	#[\Override]
	public function optimize(): bool {
		$ok = true;
		$tables = ['category', 'feed', 'entry', 'entrytmp', 'tag', 'entrytag'];

		foreach ($tables as $table) {
			$sql = 'VACUUM `_' . $table . '`';
			if ($this->pdo->exec($sql) === false) {
				$ok = false;
				$info = $this->pdo->errorInfo();
				Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
			}
		}
		return $ok;
	}
}
DatabaseDAOSQLite.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/DatabaseDAOSQLite.php'
View Content
<?php
declare(strict_types=1);

/**
 * This class is used to test database is well-constructed (SQLite).
 */
class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {

	#[\Override]
	public function tablesAreCorrect(): bool {
		$sql = "SELECT name FROM sqlite_master WHERE type='table'";
		$stm = $this->pdo->query($sql);
		$res = $stm ? $stm->fetchAll(PDO::FETCH_ASSOC) : false;
		if ($res === false) {
			return false;
		}

		$tables = [
			$this->pdo->prefix() . 'category' => false,
			$this->pdo->prefix() . 'feed' => false,
			$this->pdo->prefix() . 'entry' => false,
			$this->pdo->prefix() . 'entrytmp' => false,
			$this->pdo->prefix() . 'tag' => false,
			$this->pdo->prefix() . 'entrytag' => false,
		];
		foreach ($res as $value) {
			$tables[$value['name']] = true;
		}

		return count(array_keys($tables, true, true)) == count($tables);
	}

	/** @return array<array{name:string,type:string,notnull:bool,default:mixed}> */
	#[\Override]
	public function getSchema(string $table): array {
		$sql = 'PRAGMA table_info(' . $table . ')';
		$stm = $this->pdo->query($sql);
		return $stm ? $this->listDaoToSchema($stm->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
	}

	#[\Override]
	public function entryIsCorrect(): bool {
		return $this->checkTable('entry', [
			'id', 'guid', 'title', 'author', 'content', 'link', 'date', 'lastSeen', 'hash', 'is_read', 'is_favorite', 'id_feed', 'tags',
		]);
	}

	#[\Override]
	public function entrytmpIsCorrect(): bool {
		return $this->checkTable('entrytmp', [
			'id', 'guid', 'title', 'author', 'content', 'link', 'date', 'lastSeen', 'hash', 'is_read', 'is_favorite', 'id_feed', 'tags'
		]);
	}

	/**
	 * @param array<string,string|int|bool|null> $dao
	 * @return array{'name':string,'type':string,'notnull':bool,'default':mixed}
	 */
	#[\Override]
	public function daoToSchema(array $dao): array {
		return [
			'name'    => (string)$dao['name'],
			'type'    => strtolower((string)$dao['type']),
			'notnull' => $dao['notnull'] == '1' ? true : false,
			'default' => $dao['dflt_value'],
		];
	}

	#[\Override]
	public function size(bool $all = false): int {
		$sum = 0;
		if ($all) {
			foreach (glob(DATA_PATH . '/users/*/db.sqlite') ?: [] as $filename) {
				$sum += (@filesize($filename) ?: 0);
			}
		} else {
			$sum = (@filesize(DATA_PATH . '/users/' . $this->current_user . '/db.sqlite') ?: 0);
		}
		return $sum;
	}

	#[\Override]
	public function optimize(): bool {
		$ok = $this->pdo->exec('VACUUM') !== false;
		if (!$ok) {
			$info = $this->pdo->errorInfo();
			Minz_Log::warning(__METHOD__ . ' error : ' . json_encode($info));
		}
		return $ok;
	}
}
Days.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Days.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_Days {
	public const TODAY = 0;
	public const YESTERDAY = 1;
	public const BEFORE_YESTERDAY = 2;
}
Entry.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Entry.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_Entry extends Minz_Model {
	use FreshRSS_AttributesTrait;

	public const STATE_READ = 1;
	public const STATE_NOT_READ = 2;
	public const STATE_ALL = 3;
	public const STATE_FAVORITE = 4;
	public const STATE_NOT_FAVORITE = 8;

	/** @var numeric-string */
	private string $id = '0';
	private string $guid;
	private string $title;
	/** @var array<string> */
	private array $authors;
	private string $content;
	private string $link;
	private int $date;
	private int $lastSeen = 0;
	/** In microseconds */
	private string $date_added = '0';
	private string $hash = '';
	private ?bool $is_read;
	private ?bool $is_favorite;
	private bool $is_updated = false;
	private int $feedId;
	private ?FreshRSS_Feed $feed;
	/** @var array<string> */
	private array $tags = [];

	/**
	 * @param int|string $pubdate
	 * @param bool|int|null $is_read
	 * @param bool|int|null $is_favorite
	 * @param string|array<string> $tags
	 */
	public function __construct(int $feedId = 0, string $guid = '', string $title = '', string $authors = '', string $content = '',
			string $link = '', $pubdate = 0, $is_read = false, $is_favorite = false, $tags = '') {
		$this->_title($title);
		$this->_authors($authors);
		$this->_content($content);
		$this->_link($link);
		$this->_date($pubdate);
		$this->_isRead($is_read);
		$this->_isFavorite($is_favorite);
		$this->_feedId($feedId);
		$this->_tags($tags);
		$this->_guid($guid);
	}

	/** @param array{'id'?:string,'id_feed'?:int,'guid'?:string,'title'?:string,'author'?:string,'content'?:string,'link'?:string,'date'?:int|string,'lastSeen'?:int,
	 *		'hash'?:string,'is_read'?:bool|int,'is_favorite'?:bool|int,'tags'?:string|array<string>,'attributes'?:?string,'thumbnail'?:string,'timestamp'?:string} $dao */
	public static function fromArray(array $dao): FreshRSS_Entry {
		FreshRSS_DatabaseDAO::pdoInt($dao, ['id_feed', 'date', 'lastSeen', 'is_read', 'is_favorite']);

		if (empty($dao['content'])) {
			$dao['content'] = '';
		}

		$dao['attributes'] = empty($dao['attributes']) ? [] : json_decode($dao['attributes'], true);
		if (!is_array($dao['attributes'])) {
			$dao['attributes'] = [];
		}

		if (!empty($dao['thumbnail'])) {
			$dao['attributes']['thumbnail'] = [
				'url' => $dao['thumbnail'],
			];
		}
		$entry = new FreshRSS_Entry(
			$dao['id_feed'] ?? 0,
			$dao['guid'] ?? '',
			$dao['title'] ?? '',
			$dao['author'] ?? '',
			$dao['content'],
			$dao['link'] ?? '',
			$dao['date'] ?? 0,
			$dao['is_read'] ?? false,
			$dao['is_favorite'] ?? false,
			$dao['tags'] ?? ''
		);
		if (!empty($dao['id'])) {
			$entry->_id($dao['id']);
		}
		if (!empty($dao['timestamp'])) {
			$entry->_date(strtotime($dao['timestamp']) ?: 0);
		}
		if (isset($dao['lastSeen'])) {
			$entry->_lastSeen($dao['lastSeen']);
		}
		if (!empty($dao['attributes'])) {
			$entry->_attributes($dao['attributes']);
		}
		if (!empty($dao['hash'])) {
			$entry->_hash($dao['hash']);
		}
		return $entry;
	}

	/**
	 * @param Traversable<array{'id'?:string,'id_feed'?:int,'guid'?:string,'title'?:string,'author'?:string,'content'?:string,'link'?:string,'date'?:int|string,'lastSeen'?:int,
	 *	'hash'?:string,'is_read'?:bool|int,'is_favorite'?:bool|int,'tags'?:string|array<string>,'attributes'?:?string,'thumbnail'?:string,'timestamp'?:string}> $daos
	 * @return Traversable<FreshRSS_Entry>
	 */
	public static function fromTraversable(Traversable $daos): Traversable {
		foreach ($daos as $dao) {
			yield FreshRSS_Entry::fromArray($dao);
		}
	}

	/** @return numeric-string */
	public function id(): string {
		return $this->id;
	}
	public function guid(): string {
		return $this->guid;
	}
	public function title(): string {
		$title = '';

		if ($this->title === '') {
			// used while fetching the article from feed and store it in the database
			$title = $this->guid();
		} else {
			// used while fetching from the database
			if ($this->title !== $this->guid) {
				$title = $this->title;
			} else {
				$content = trim(strip_tags($this->content(false)));
				$title = trim(mb_substr($content, 0, MAX_CHARS_EMPTY_FEED_TITLE, 'UTF-8'));

				if ($title === '') {
					$title = $this->guid();
				} elseif (strlen($content) > strlen($title)) {
					$title .= '…';
				}
			}
		}
		return $title;
	}
	/** @deprecated */
	public function author(): string {
		return $this->authors(true);
	}
	/**
	 * @phpstan-return ($asString is true ? string : array<string>)
	 * @return string|array<string>
	 */
	public function authors(bool $asString = false) {
		if ($asString) {
			return $this->authors == null ? '' : ';' . implode('; ', $this->authors);
		} else {
			return $this->authors;
		}
	}

	/**
	 * Basic test without ambition to catch all cases such as unquoted addresses, variants of entities, HTML comments, etc.
	 */
	private static function containsLink(string $html, string $link): bool {
		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;
		$medium = $enclosure['medium'] ?? '';
		$mime = $enclosure['type'] ?? '';

		return ($elink != '' && $medium === 'image') || strpos($mime, 'image') === 0 ||
			($mime == '' && $length == 0 && preg_match('/[.](avif|gif|jpe?g|png|svg|webp)([?#]|$)/i', $elink));
	}

	/**
	 * Provides the original content without additional content potentially added by loadCompleteContent().
	 */
	public function originalContent(): string {
		return preg_replace('#<!-- FULLCONTENT start //-->.*<!-- FULLCONTENT end //-->#s', '', $this->content) ?? '';
	}

	/**
	 * @param bool $withEnclosures Set to true to include the enclosures in the returned HTML, false otherwise.
	 * @param bool $allowDuplicateEnclosures Set to false to remove obvious enclosure duplicates (based on simple string comparison), true otherwise.
	 * @return string HTML content
	 */
	public function content(bool $withEnclosures = true, bool $allowDuplicateEnclosures = false): string {
		if (!$withEnclosures) {
			return $this->content;
		}

		$content = $this->content;

		$thumbnailAttribute = $this->attributeArray('thumbnail') ?? [];
		if (!empty($thumbnailAttribute['url'])) {
			$elink = $thumbnailAttribute['url'];
			if (is_string($elink) && ($allowDuplicateEnclosures || !self::containsLink($content, $elink))) {
				$content .= <<<HTML
<figure class="enclosure">
	<p class="enclosure-content">
		<img class="enclosure-thumbnail" src="{$elink}" alt="" />
	</p>
</figure>
HTML;
			}
		}

		$attributeEnclosures = $this->attributeArray('enclosures');
		if (empty($attributeEnclosures)) {
			return $content;
		}

		foreach ($attributeEnclosures as $enclosure) {
			if (!is_array($enclosure)) {
				continue;
			}
			$elink = $enclosure['url'] ?? '';
			if ($elink == '' || !is_string($elink)) {
				continue;
			}
			if (!$allowDuplicateEnclosures && self::containsLink($content, $elink)) {
				continue;
			}
			$credits = $enclosure['credit'] ?? '';
			$description = nl2br($enclosure['description'] ?? '', true);
			$length = $enclosure['length'] ?? 0;
			$medium = $enclosure['medium'] ?? '';
			$mime = $enclosure['type'] ?? '';
			$thumbnails = $enclosure['thumbnails'] ?? null;
			if (!is_array($thumbnails)) {
				$thumbnails = [];
			}
			$etitle = $enclosure['title'] ?? '';

			$content .= "\n";
			$content .= '<figure class="enclosure">';

			foreach ($thumbnails as $thumbnail) {
				$content .= '<p><img class="enclosure-thumbnail" src="' . $thumbnail . '" alt="" title="' . $etitle . '" /></p>';
			}

			if (self::enclosureIsImage($enclosure)) {
				$content .= '<p class="enclosure-content"><img src="' . $elink . '" alt="" title="' . $etitle . '" /></p>';
			} elseif ($medium === 'audio' || strpos($mime, 'audio') === 0) {
				$content .= '<p class="enclosure-content"><audio preload="none" src="' . $elink
					. ($length == null ? '' : '" data-length="' . (int)$length)
					. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
					. '" controls="controls" title="' . $etitle . '"></audio> <a download="" href="' . $elink . '">💾</a></p>';
			} elseif ($medium === 'video' || strpos($mime, 'video') === 0) {
				$content .= '<p class="enclosure-content"><video preload="none" src="' . $elink
					. ($length == null ? '' : '" data-length="' . (int)$length)
					. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
					. '" controls="controls" title="' . $etitle . '"></video> <a download="" href="' . $elink . '">💾</a></p>';
			} else {	//e.g. application, text, unknown
				$content .= '<p class="enclosure-content"><a download="" href="' . $elink
					. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
					. ($medium == '' ? '' : '" data-medium="' . htmlspecialchars($medium, ENT_COMPAT, 'UTF-8'))
					. '" title="' . $etitle . '">💾</a></p>';
			}

			if ($credits != '') {
				if (!is_array($credits)) {
					$credits = [$credits];
				}
				foreach ($credits as $credit) {
					$content .= '<p class="enclosure-credits">© ' . $credit . '</p>';
				}
			}
			if ($description != '') {
				$content .= '<figcaption class="enclosure-description">' . $description . '</figcaption>';
			}
			$content .= "</figure>\n";
		}

		return $content;
	}

	/** @return Traversable<array{'url':string,'type'?:string,'medium'?:string,'length'?:int,'title'?:string,'description'?:string,'credit'?:string|array<string>,'height'?:int,'width'?:int,'thumbnails'?:array<string>}> */
	public function enclosures(bool $searchBodyImages = false): Traversable {
		$attributeEnclosures = $this->attributeArray('enclosures');
		if (is_iterable($attributeEnclosures)) {
			// FreshRSS 1.20.1+: The enclosures are saved as attributes
			/** @var iterable<array{'url':string,'type'?:string,'medium'?:string,'length'?:int,'title'?:string,'description'?:string,'credit'?:string|array<string>,'height'?:int,'width'?:int,'thumbnails'?:array<string>}> $attributeEnclosures */
			yield from $attributeEnclosures;
		}
		try {
			$searchEnclosures = !is_iterable($attributeEnclosures) && (strpos($this->content, '<p class="enclosure-content') !== false);
			$searchBodyImages &= (stripos($this->content, '<img') !== false);
			$xpath = null;
			if ($searchEnclosures || $searchBodyImages) {
				$dom = new DOMDocument();
				$dom->loadHTML('<?xml version="1.0" encoding="UTF-8" ?>' . $this->content, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
				$xpath = new DOMXPath($dom);
			}
			if ($searchEnclosures && $xpath !== null) {
				// Legacy code for database entries < FreshRSS 1.20.1
				$enclosures = $xpath->query('//div[@class="enclosure"]/p[@class="enclosure-content"]/*[@src]');
				if (!empty($enclosures)) {
					foreach ($enclosures as $enclosure) {
						if (!($enclosure instanceof DOMElement)) {
							continue;
						}
						$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);
					}
				}
			}
			if ($searchBodyImages && $xpath !== null) {
				$images = $xpath->query('//img');
				if (!empty($images)) {
					foreach ($images as $img) {
						if (!($img instanceof DOMElement)) {
							continue;
						}
						$src = $img->getAttribute('src');
						if ($src == null) {
							$src = $img->getAttribute('data-src');
						}
						if ($src != null) {
							$result = [
								'url' => $src,
								'medium' => 'image',
							];
							yield Minz_Helper::htmlspecialchars_utf8($result);
						}
					}
				}
			}
		} catch (Exception $ex) {
			Minz_Log::debug(__METHOD__ . ' ' . $ex->getMessage());
		}
	}

	/**
	 * @return array{'url':string,'height'?:int,'width'?:int,'time'?:string}|null
	 */
	public function thumbnail(bool $searchEnclosures = true): ?array {
		$thumbnail = $this->attributeArray('thumbnail') ?? [];
		// First, use the provided thumbnail, if any
		if (!empty($thumbnail['url'])) {
			/** @var array{'url':string,'height'?:int,'width'?:int,'time'?:string} $thumbnail */
			return $thumbnail;
		}
		if ($searchEnclosures) {
			foreach ($this->enclosures(true) as $enclosure) {
				// Second, search each enclosure’s thumbnails
				if (!empty($enclosure['thumbnails'][0])) {
					foreach ($enclosure['thumbnails'] as $src) {
						if (is_string($src)) {
							return [
								'url' => $src,
								'medium' => 'image',
							];
						}
					}
				}
				// Third, check whether each enclosure itself is an appropriate image
				if (self::enclosureIsImage($enclosure)) {
					return $enclosure;
				}
			}
		}
		return null;
	}

	/** @return string HTML-encoded link of the entry */
	public function link(): string {
		return $this->link;
	}
	/**
	 * @phpstan-return ($raw is false ? string : int)
	 * @return string|int
	 */
	public function date(bool $raw = false) {
		if ($raw) {
			return $this->date;
		}
		return timestamptodate($this->date);
	}
	public function machineReadableDate(): string {
		return @date(DATE_ATOM, $this->date);
	}

	public function lastSeen(): int {
		return $this->lastSeen;
	}

	/**
	 * @phpstan-return ($raw is false ? string : ($microsecond is true ? string : int))
	 * @return int|string
	 */
	public function dateAdded(bool $raw = false, bool $microsecond = false) {
		if ($raw) {
			if ($microsecond) {
				return $this->date_added;
			} else {
				return (int)substr($this->date_added, 0, -6);
			}
		} else {
			$date = (int)substr($this->date_added, 0, -6);
			return timestamptodate($date);
		}
	}
	public function isRead(): ?bool {
		return $this->is_read;
	}
	public function isFavorite(): ?bool {
		return $this->is_favorite;
	}

	/**
	 * Returns whether the entry has been modified since it was inserted in database.
	 * @returns bool `true` if the entry already existed (and has been modified), `false` if the entry is new (or unmodified).
	 */
	public function isUpdated(): ?bool {
		return $this->is_updated;
	}

	public function _isUpdated(bool $value): void {
		$this->is_updated = $value;
	}

	public function feed(): ?FreshRSS_Feed {
		if ($this->feed === null) {
			$feedDAO = FreshRSS_Factory::createFeedDao();
			$this->feed = $feedDAO->searchById($this->feedId);
		}
		return $this->feed;
	}

	public function feedId(): int {
		return $this->feedId;
	}

	/**
	 * @phpstan-return ($asString is true ? string : array<string>)
	 * @return string|array<string>
	 */
	public function tags(bool $asString = false) {
		if ($asString) {
			return $this->tags == null ? '' : '#' . implode(' #', $this->tags);
		} else {
			return $this->tags;
		}
	}

	public function hash(): string {
		if ($this->hash == '') {
			//Do not include $this->date because it may be automatically generated when lacking
			$this->hash = md5($this->link . $this->title . $this->authors(true) . $this->originalContent() . $this->tags(true));
		}
		return $this->hash;
	}

	public function _hash(string $value): string {
		$value = trim($value);
		if (ctype_xdigit($value)) {
			$this->hash = substr($value, 0, 32);
		}
		return $this->hash;
	}

	/** @param int|numeric-string $value String is for compatibility with 32-bit platforms */
	public function _id($value): void {
		if (is_int($value)) {
			$value = (string)$value;
		}
		$this->id = $value;
		if ($this->date_added == 0) {
			$this->date_added = $value;
		}
	}
	public function _guid(string $value): void {
		$value = trim($value);
		if (empty($value)) {
			$value = $this->link;
			if (empty($value)) {
				$value = $this->hash();
			}
		}
		$this->guid = $value;
	}
	public function _title(string $value): void {
		$this->hash = '';
		$this->title = trim($value);
	}
	/** @deprecated */
	public function _author(string $value): void {
		$this->_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 = Minz_Helper::htmlspecialchars_utf8($value);
			} else {
				$value = preg_split('/\s*[,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
			}
		}
		$this->authors = $value;
	}
	public function _content(string $value): void {
		$this->hash = '';
		$this->content = $value;
	}
	public function _link(string $value): void {
		$this->hash = '';
		$this->link = trim($value);
	}
	/** @param int|string $value */
	public function _date($value): void {
		$value = (int)$value;
		$this->date = $value > 1 ? $value : time();
	}

	public function _lastSeen(int $value): void {
		$this->lastSeen = $value > 0 ? $value : 0;
	}

	/** @param int|string $value */
	public function _dateAdded($value, bool $microsecond = false): void {
		if ($microsecond) {
			$this->date_added = (string)($value);
		} else {
			$this->date_added = $value . '000000';
		}
	}
	/** @param bool|int|null $value */
	public function _isRead($value): void {
		$this->is_read = $value === null ? null : (bool)$value;
	}
	/** @param bool|int|null $value */
	public function _isFavorite($value): void {
		$this->is_favorite = $value === null ? null : (bool)$value;
	}

	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): void {
		$this->feed = null;
		$this->feedId = (int)$id;
	}

	/** @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) ?: [];
		}
		$this->tags = $value;
	}

	public function matches(FreshRSS_BooleanSearch $booleanSearch): bool {
		$ok = true;
		foreach ($booleanSearch->searches() as $filter) {
			if ($filter instanceof FreshRSS_BooleanSearch) {
				// BooleanSearches are combined by AND (default) or OR or AND NOT (special cases) operators and are recursive
				switch ($filter->operator()) {
					case 'OR':
						$ok |= $this->matches($filter);
						break;
					case 'OR NOT':
						$ok |= !$this->matches($filter);
						break;
					case 'AND NOT':
						$ok &= !$this->matches($filter);
						break;
					case 'AND':
					default:
						$ok &= $this->matches($filter);
						break;
				}
			} elseif ($filter instanceof FreshRSS_Search) {
				// Searches are combined by OR and are not recursive
				$ok = true;
				if ($filter->getEntryIds()) {
					$ok &= in_array($this->id, $filter->getEntryIds(), true);
				}
				if ($ok && $filter->getNotEntryIds()) {
					$ok &= !in_array($this->id, $filter->getNotEntryIds(), true);
				}
				if ($ok && $filter->getMinDate()) {
					$ok &= strnatcmp($this->id, $filter->getMinDate() . '000000') >= 0;
				}
				if ($ok && $filter->getNotMinDate()) {
					$ok &= strnatcmp($this->id, $filter->getNotMinDate() . '000000') < 0;
				}
				if ($ok && $filter->getMaxDate()) {
					$ok &= strnatcmp($this->id, $filter->getMaxDate() . '000000') <= 0;
				}
				if ($ok && $filter->getNotMaxDate()) {
					$ok &= strnatcmp($this->id, $filter->getNotMaxDate() . '000000') > 0;
				}
				if ($ok && $filter->getMinPubdate()) {
					$ok &= $this->date >= $filter->getMinPubdate();
				}
				if ($ok && $filter->getNotMinPubdate()) {
					$ok &= $this->date < $filter->getNotMinPubdate();
				}
				if ($ok && $filter->getMaxPubdate()) {
					$ok &= $this->date <= $filter->getMaxPubdate();
				}
				if ($ok && $filter->getNotMaxPubdate()) {
					$ok &= $this->date > $filter->getNotMaxPubdate();
				}
				if ($ok && $filter->getFeedIds()) {
					$ok &= in_array($this->feedId, $filter->getFeedIds(), true);
				}
				if ($ok && $filter->getNotFeedIds()) {
					$ok &= !in_array($this->feedId, $filter->getNotFeedIds(), true);
				}
				if ($ok && $filter->getAuthor()) {
					foreach ($filter->getAuthor() as $author) {
						$ok &= stripos(implode(';', $this->authors), $author) !== false;
					}
				}
				if ($ok && $filter->getNotAuthor()) {
					foreach ($filter->getNotAuthor() as $author) {
						$ok &= stripos(implode(';', $this->authors), $author) === false;
					}
				}
				if ($ok && $filter->getIntitle()) {
					foreach ($filter->getIntitle() as $title) {
						$ok &= stripos($this->title, $title) !== false;
					}
				}
				if ($ok && $filter->getNotIntitle()) {
					foreach ($filter->getNotIntitle() as $title) {
						$ok &= stripos($this->title, $title) === false;
					}
				}
				if ($ok && $filter->getTags()) {
					foreach ($filter->getTags() as $tag2) {
						$found = false;
						foreach ($this->tags as $tag1) {
							if (strcasecmp($tag1, $tag2) === 0) {
								$found = true;
							}
						}
						$ok &= $found;
					}
				}
				if ($ok && $filter->getNotTags()) {
					foreach ($filter->getNotTags() as $tag2) {
						$found = false;
						foreach ($this->tags as $tag1) {
							if (strcasecmp($tag1, $tag2) === 0) {
								$found = true;
							}
						}
						$ok &= !$found;
					}
				}
				if ($ok && $filter->getInurl()) {
					foreach ($filter->getInurl() as $url) {
						$ok &= stripos($this->link, $url) !== false;
					}
				}
				if ($ok && $filter->getNotInurl()) {
					foreach ($filter->getNotInurl() as $url) {
						$ok &= stripos($this->link, $url) === false;
					}
				}
				if ($ok && $filter->getSearch()) {
					foreach ($filter->getSearch() as $needle) {
						$ok &= (stripos($this->title, $needle) !== false || stripos($this->content, $needle) !== false);
					}
				}
				if ($ok && $filter->getNotSearch()) {
					foreach ($filter->getNotSearch() as $needle) {
						$ok &= (stripos($this->title, $needle) === false && stripos($this->content, $needle) === false);
					}
				}
				if ($ok) {
					return true;
				}
			}
		}
		return (bool)$ok;
	}

	/** @param array<string,bool|int> $titlesAsRead */
	public function applyFilterActions(array $titlesAsRead = []): void {
		$feed = $this->feed;
		if ($feed === null) {
			return;
		}
		if (!$this->isRead()) {
			if ($feed->attributeBoolean('read_upon_reception') ?? FreshRSS_Context::userConf()->mark_when['reception']) {
				$this->_isRead(true);
				Minz_ExtensionManager::callHook('entry_auto_read', $this, 'upon_reception');
			}
			if (!empty($titlesAsRead[$this->title()])) {
				Minz_Log::debug('Mark title as read: ' . $this->title());
				$this->_isRead(true);
				Minz_ExtensionManager::callHook('entry_auto_read', $this, 'same_title_in_feed');
			}
		}
		FreshRSS_Context::userConf()->applyFilterActions($this);
		if ($feed->category() !== null) {
			$feed->category()->applyFilterActions($this);
		}
		$feed->applyFilterActions($this);
	}

	public function isDay(int $day, int $today): bool {
		$date = $this->dateAdded(true);
		switch ($day) {
			case FreshRSS_Days::TODAY:
				$tomorrow = $today + 86400;
				return $date >= $today && $date < $tomorrow;
			case FreshRSS_Days::YESTERDAY:
				$yesterday = $today - 86400;
				return $date >= $yesterday && $date < $today;
			case FreshRSS_Days::BEFORE_YESTERDAY:
				$yesterday = $today - 86400;
				return $date < $yesterday;
			default:
				return false;
		}
	}

	/**
	 * @param string $url Overridden URL. Will default to the entry URL.
	 * @throws Minz_Exception
	 */
	public function getContentByParsing(string $url = '', int $maxRedirs = 3): string {
		$url = $url ?: htmlspecialchars_decode($this->link(), ENT_QUOTES);
		$feed = $this->feed();
		if ($url === '' || $feed === null || $feed->pathEntries() === '') {
			return '';
		}

		$cachePath = $feed->cacheFilename($url . '#' . $feed->pathEntries());
		$html = httpGet($url, $cachePath, 'html', $feed->attributes(), $feed->curlOptions());
		if (strlen($html) > 0) {
			$doc = new DOMDocument();
			$doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
			$xpath = new DOMXPath($doc);

			if ($maxRedirs > 0) {
				//Follow any HTML redirection
				$metas = $xpath->query('//meta[@content]') ?: [];
				foreach ($metas as $meta) {
					if ($meta instanceof DOMElement && strtolower(trim($meta->getAttribute('http-equiv'))) === 'refresh') {
						$refresh = preg_replace('/^[0-9.; ]*\s*(url\s*=)?\s*/i', '', trim($meta->getAttribute('content')));
						$refresh = SimplePie_Misc::absolutize_url($refresh, $url);
						if ($refresh != false && $refresh !== $url) {
							return $this->getContentByParsing($refresh, $maxRedirs - 1);
						}
					}
				}
			}

			$base = $xpath->evaluate('normalize-space(//base/@href)');
			if ($base == false || !is_string($base)) {
				$base = $url;
			} elseif (substr($base, 0, 2) === '//') {
				//Protocol-relative URLs "//www.example.net"
				$base = (parse_url($url, PHP_URL_SCHEME) ?? 'https') . ':' . $base;
			}

			$content = '';
			$cssSelector = htmlspecialchars_decode($feed->pathEntries(), ENT_QUOTES);
			$cssSelector = trim($cssSelector, ', ');
			$nodes = $xpath->query((new Gt\CssXPath\Translator($cssSelector, '//'))->asXPath());
			if ($nodes != false) {
				$path_entries_filter = $feed->attributeString('path_entries_filter') ?? '';
				$path_entries_filter = trim($path_entries_filter, ', ');
				foreach ($nodes as $node) {
					if ($path_entries_filter !== '') {
						$filterednodes = $xpath->query((new Gt\CssXPath\Translator($path_entries_filter))->asXPath(), $node) ?: [];
						foreach ($filterednodes as $filterednode) {
							if ($filterednode->parentNode === null) {
								continue;
							}
							$filterednode->parentNode->removeChild($filterednode);
						}
					}
					$content .= $doc->saveHTML($node) . "\n";
				}
			}
			$html = trim(sanitizeHTML($content, $base));
			return $html;
		} else {
			throw new Minz_Exception();
		}
	}

	public function loadCompleteContent(bool $force = false): bool {
		// Gestion du contenu
		// Trying to fetch full article content even when feeds do not propose it
		$feed = $this->feed();
		if ($feed != null && trim($feed->pathEntries()) != '') {
			$entryDAO = FreshRSS_Factory::createEntryDao();
			$entry = $force ? null : $entryDAO->searchByGuid($this->feedId, $this->guid);

			if ($entry) {
				// l’article existe déjà en BDD, en se contente de recharger ce contenu
				$this->content = $entry->content(false);
			} else {
				try {
					// The article is not yet in the database, so let’s fetch it
					$fullContent = $this->getContentByParsing();
					if ('' !== $fullContent) {
						$fullContent = "<!-- FULLCONTENT start //-->{$fullContent}<!-- FULLCONTENT end //-->";
						$originalContent = $this->originalContent();
						switch ($feed->attributeString('content_action')) {
							case 'prepend':
								$this->content = $fullContent . $originalContent;
								break;
							case 'append':
								$this->content = $originalContent . $fullContent;
								break;
							case 'replace':
							default:
								$this->content = $fullContent;
								break;
						}

						return true;
					}
				} catch (Exception $e) {
					// rien à faire, on garde l’ancien contenu(requête a échoué)
					Minz_Log::warning($e->getMessage());
				}
			}
		}
		return false;
	}

	/**
	 * @return array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'lastSeen':int,
	 * 	'hash':string,'is_read':?bool,'is_favorite':?bool,'id_feed':int,'tags':string,'attributes':array<string,mixed>}
	 */
	public function toArray(): array {
		return [
			'id' => $this->id(),
			'guid' => $this->guid(),
			'title' => $this->title(),
			'author' => $this->authors(true),
			'content' => $this->content(false),
			'link' => $this->link(),
			'date' => $this->date(true),
			'lastSeen' => $this->lastSeen(),
			'hash' => $this->hash(),
			'is_read' => $this->isRead(),
			'is_favorite' => $this->isFavorite(),
			'id_feed' => $this->feedId(),
			'tags' => $this->tags(true),
			'attributes' => $this->attributes(),
		];
	}

	/**
	 * @return array{array<string>,array<string>} Array of first tags to show, then array of remaining tags
	 */
	public function tagsFormattingHelper(): array {
		$firstTags = [];
		$remainingTags = [];

		if (FreshRSS_Context::hasUserConf() && in_array(FreshRSS_Context::userConf()->show_tags, ['b', 'f', 'h'], true)) {
			$maxTagsDisplayed = (int)FreshRSS_Context::userConf()->show_tags_max;
			$tags = $this->tags();
			if (!empty($tags)) {
				if ($maxTagsDisplayed > 0) {
					$firstTags = array_slice($tags, 0, $maxTagsDisplayed);
					$remainingTags = array_slice($tags, $maxTagsDisplayed);
				} else {
					$firstTags = $tags;
				}
			}
		}
		return [$firstTags,$remainingTags];
	}

	/**
	 * Integer format conversion for Google Reader API format
	 * @param numeric-string|int $dec Decimal number
	 * @return string 64-bit hexa http://code.google.com/p/google-reader-api/wiki/ItemId
	 */
	private static function dec2hex($dec): string {
		return PHP_INT_SIZE < 8 ? // 32-bit ?
			str_pad(gmp_strval(gmp_init($dec, 10), 16), 16, '0', STR_PAD_LEFT) :
			str_pad(dechex((int)($dec)), 16, '0', STR_PAD_LEFT);
	}

	/**
	 * Some clients (tested with News+) would fail if sending too long item content
	 * @var int
	 */
	public const API_MAX_COMPAT_CONTENT_LENGTH = 500000;

	/**
	 * N.B.: To avoid expensive lookups, ensure to set `$entry->_feed($feed)` before calling this function.
	 * @param string $mode Set to `'compat'` to use an alternative Unicode representation for problematic HTML special characters not decoded by some clients;
	 * 	set to `'freshrss'` for using FreshRSS additions for internal use (e.g. export/import).
	 * @param array<string> $labels List of labels associated to this entry.
	 * @return array<string,mixed> A representation of this entry in a format compatible with Google Reader API
	 */
	public function toGReader(string $mode = '', array $labels = []): array {

		$feed = $this->feed();
		$category = $feed == null ? null : $feed->category();

		$item = [
			'id' => 'tag:google.com,2005:reader/item/' . self::dec2hex($this->id()),
			'crawlTimeMsec' => substr($this->dateAdded(true, true), 0, -3),
			'timestampUsec' => '' . $this->dateAdded(true, true), //EasyRSS & Reeder
			'published' => $this->date(true),
			// 'updated' => $this->date(true),
			'title' => $this->title(),
			'canonical' => [
				['href' => htmlspecialchars_decode($this->link(), ENT_QUOTES)],
			],
			'alternate' => [
				[
					'href' => htmlspecialchars_decode($this->link(), ENT_QUOTES),
					'type' => 'text/html',
				],
			],
			'categories' => [
				'user/-/state/com.google/reading-list',
			],
			'origin' => [
				'streamId' => 'feed/' . $this->feedId,
			],
		];
		if ($mode === 'compat') {
			$item['title'] = escapeToUnicodeAlternative($this->title(), false);
			unset($item['alternate'][0]['type']);
			$item['summary'] = [
				'content' => mb_strcut($this->content(true), 0, self::API_MAX_COMPAT_CONTENT_LENGTH, 'UTF-8'),
			];
		} else {
			$item['content'] = [
				'content' => $this->content(false),
			];
		}
		if ($mode === 'freshrss') {
			$item['guid'] = $this->guid();
		}
		if ($category != null && $mode !== 'freshrss') {
			$item['categories'][] = 'user/-/label/' . htmlspecialchars_decode($category->name(), ENT_QUOTES);
		}
		if ($feed != null) {
			$item['origin']['htmlUrl'] = htmlspecialchars_decode($feed->website());
			$item['origin']['title'] = $feed->name();	//EasyRSS
			if ($mode === 'compat') {
				$item['origin']['title'] = escapeToUnicodeAlternative($feed->name(), true);
			} elseif ($mode === 'freshrss') {
				$item['origin']['feedUrl'] = htmlspecialchars_decode($feed->url());
			}
		}
		foreach ($this->enclosures() as $enclosure) {
			if (!empty($enclosure['url'])) {
				$media = [
						'href' => $enclosure['url'],
						'type' => $enclosure['type'] ?? $enclosure['medium'] ??
							(self::enclosureIsImage($enclosure) ? 'image' : ''),
					];
				if (!empty($enclosure['length'])) {
					$media['length'] = (int)$enclosure['length'];
				}
				$item['enclosure'][] = $media;
			}
		}
		$author = $this->authors(true);
		$author = trim($author, '; ');
		if ($author != '') {
			if ($mode === 'compat') {
				$item['author'] = escapeToUnicodeAlternative($author, false);
			} else {
				$item['author'] = $author;
			}
		}
		if ($this->isRead()) {
			$item['categories'][] = 'user/-/state/com.google/read';
		} elseif ($mode === 'freshrss') {
			$item['categories'][] = 'user/-/state/com.google/unread';
		}
		if ($this->isFavorite()) {
			$item['categories'][] = 'user/-/state/com.google/starred';
		}
		foreach ($labels as $labelName) {
			$item['categories'][] = 'user/-/label/' . htmlspecialchars_decode($labelName, ENT_QUOTES);
		}
		foreach ($this->tags() as $tagName) {
			$item['categories'][] = htmlspecialchars_decode($tagName, ENT_QUOTES);
		}
		return $item;
	}
}
EntryDAO.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/EntryDAO.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_EntryDAO extends Minz_ModelPdo {

	public static function isCompressed(): bool {
		return true;
	}

	public static function hasNativeHex(): bool {
		return true;
	}

	protected static function sqlConcat(string $s1, string $s2): string {
		return 'CONCAT(' . $s1 . ',' . $s2 . ')';	//MySQL
	}

	public static function sqlHexDecode(string $x): string {
		return 'unhex(' . $x . ')';
	}

	public static function sqlHexEncode(string $x): string {
		return 'hex(' . $x . ')';
	}

	public static function sqlIgnoreConflict(string $sql): string {
		return str_replace('INSERT INTO ', 'INSERT IGNORE INTO ', $sql);
	}

	private function updateToMediumBlob(): bool {
		if ($this->pdo->dbType() !== 'mysql') {
			return false;
		}
		Minz_Log::warning('Update MySQL table to use MEDIUMBLOB...');

		$sql = <<<'SQL'
ALTER TABLE `_entry` MODIFY `content_bin` MEDIUMBLOB;
ALTER TABLE `_entrytmp` MODIFY `content_bin` MEDIUMBLOB;
SQL;
		try {
			$ok = $this->pdo->exec($sql) !== false;
		} catch (Exception $e) {
			$ok = false;
			Minz_Log::error(__method__ . ' error: ' . $e->getMessage());
		}
		return $ok;
	}

	protected function addColumn(string $name): bool {
		if ($this->pdo->inTransaction()) {
			$this->pdo->commit();
		}
		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
	/** @param array<string|int> $errorInfo */
	protected function autoUpdateDb(array $errorInfo): bool {
		if (isset($errorInfo[0])) {
			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
				$errorLines = explode("\n", (string)$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 (isset($errorInfo[1])) {
			// May be a string or an int
			if ($errorInfo[1] == FreshRSS_DatabaseDAO::ER_DATA_TOO_LONG) {
				if (stripos((string)$errorInfo[2], 'content_bin') !== false) {
					return $this->updateToMediumBlob();	//v1.15.0
				}
			}
		}
		return false;
	}

	/**
	 * @var PDOStatement|null|false
	 */
	private $addEntryPrepared = false;

	/** @param array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'lastSeen':int,'hash':string,
	 *		'is_read':bool|int|null,'is_favorite':bool|int|null,'id_feed':int,'tags':string,'attributes'?:null|string|array<string,mixed>} $valuesTmp */
	public function addEntry(array $valuesTmp, bool $useTmpTable = true): bool {
		if ($this->addEntryPrepared == null) {
			$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, 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, :attributes)');
			$this->addEntryPrepared = $this->pdo->prepare($sql);
		}
		if ($this->addEntryPrepared) {
			$this->addEntryPrepared->bindParam(':id', $valuesTmp['id']);
			$valuesTmp['guid'] = substr($valuesTmp['guid'], 0, 767);
			$valuesTmp['guid'] = safe_ascii($valuesTmp['guid']);
			$this->addEntryPrepared->bindParam(':guid', $valuesTmp['guid']);
			$valuesTmp['title'] = mb_strcut($valuesTmp['title'], 0, 8192, 'UTF-8');
			$valuesTmp['title'] = safe_utf8($valuesTmp['title']);
			$this->addEntryPrepared->bindParam(':title', $valuesTmp['title']);
			$valuesTmp['author'] = mb_strcut($valuesTmp['author'], 0, 1024, 'UTF-8');
			$valuesTmp['author'] = safe_utf8($valuesTmp['author']);
			$this->addEntryPrepared->bindParam(':author', $valuesTmp['author']);
			$valuesTmp['content'] = safe_utf8($valuesTmp['content']);
			$this->addEntryPrepared->bindParam(':content', $valuesTmp['content']);
			$valuesTmp['link'] = substr($valuesTmp['link'], 0, 16383);
			$valuesTmp['link'] = safe_ascii($valuesTmp['link']);
			$this->addEntryPrepared->bindParam(':link', $valuesTmp['link']);
			$this->addEntryPrepared->bindParam(':date', $valuesTmp['date'], PDO::PARAM_INT);
			if (empty($valuesTmp['lastSeen'])) {
				$valuesTmp['lastSeen'] = time();
			}
			$this->addEntryPrepared->bindParam(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
			$valuesTmp['is_read'] = $valuesTmp['is_read'] ? 1 : 0;
			$this->addEntryPrepared->bindParam(':is_read', $valuesTmp['is_read'], PDO::PARAM_INT);
			$valuesTmp['is_favorite'] = $valuesTmp['is_favorite'] ? 1 : 0;
			$this->addEntryPrepared->bindParam(':is_favorite', $valuesTmp['is_favorite'], PDO::PARAM_INT);
			$this->addEntryPrepared->bindParam(':id_feed', $valuesTmp['id_feed'], PDO::PARAM_INT);
			$valuesTmp['tags'] = mb_strcut($valuesTmp['tags'], 0, 2048, '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 | JSON_UNESCAPED_UNICODE));

			if (static::hasNativeHex()) {
				$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
			} else {
				$valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']);
				$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hashBin']);
			}
		}
		if ($this->addEntryPrepared && $this->addEntryPrepared->execute()) {
			return true;
		} else {
			$info = $this->addEntryPrepared == null ? $this->pdo->errorInfo() : $this->addEntryPrepared->errorInfo();
			if ($this->autoUpdateDb($info)) {
				$this->addEntryPrepared = null;
				return $this->addEntry($valuesTmp);
			} elseif ((int)((int)$info[0] / 1000) !== 23) {	//Filter out "SQLSTATE Class code 23: Constraint Violation" because of expected duplicate entries
				Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
					. ' while adding entry in feed ' . $valuesTmp['id_feed'] . ' with title: ' . $valuesTmp['title']);
			}
			return false;
		}
	}

	public function commitNewEntries(): bool {
		$sql = <<<'SQL'
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, attributes
)
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;

DELETE FROM `_entrytmp` WHERE id <= @rank;
SQL;
		$hadTransaction = $this->pdo->inTransaction();
		if (!$hadTransaction) {
			$this->pdo->beginTransaction();
		}
		$result = $this->pdo->exec($sql) !== false;
		if (!$hadTransaction) {
			$this->pdo->commit();
		}
		return $result;
	}

	private ?PDOStatement $updateEntryPrepared = null;

	/** @param array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'lastSeen':int,'hash':string,
	 *		'is_read':bool|int|null,'is_favorite':bool|int|null,'id_feed':int,'tags':string,'attributes':array<string,mixed>} $valuesTmp */
	public function updateEntry(array $valuesTmp): bool {
		if (!isset($valuesTmp['is_read'])) {
			$valuesTmp['is_read'] = null;
		}
		if (!isset($valuesTmp['is_favorite'])) {
			$valuesTmp['is_favorite'] = null;
		}

		if ($this->updateEntryPrepared === null) {
			$sql = 'UPDATE `_entry` '
				. 'SET title=:title, author=:author, '
				. (static::isCompressed() ? 'content_bin=COMPRESS(:content)' : 'content=:content')
				. ', link=:link, date=:date, `lastSeen`=:last_seen'
				. ', hash=' . static::sqlHexDecode(':hash')
				. ', is_read=COALESCE(:is_read, is_read)'
				. ', is_favorite=COALESCE(:is_favorite, is_favorite)'
				. ', tags=:tags, attributes=:attributes '
				. 'WHERE id_feed=:id_feed AND guid=:guid';
			$this->updateEntryPrepared = $this->pdo->prepare($sql) ?: null;
		}
		if ($this->updateEntryPrepared) {
			$valuesTmp['guid'] = substr($valuesTmp['guid'], 0, 767);
			$valuesTmp['guid'] = safe_ascii($valuesTmp['guid']);
			$this->updateEntryPrepared->bindParam(':guid', $valuesTmp['guid']);
			$valuesTmp['title'] = mb_strcut($valuesTmp['title'], 0, 8192, 'UTF-8');
			$valuesTmp['title'] = safe_utf8($valuesTmp['title']);
			$this->updateEntryPrepared->bindParam(':title', $valuesTmp['title']);
			$valuesTmp['author'] = mb_strcut($valuesTmp['author'], 0, 1024, 'UTF-8');
			$valuesTmp['author'] = safe_utf8($valuesTmp['author']);
			$this->updateEntryPrepared->bindParam(':author', $valuesTmp['author']);
			$valuesTmp['content'] = safe_utf8($valuesTmp['content']);
			$this->updateEntryPrepared->bindParam(':content', $valuesTmp['content']);
			$valuesTmp['link'] = substr($valuesTmp['link'], 0, 16383);
			$valuesTmp['link'] = safe_ascii($valuesTmp['link']);
			$this->updateEntryPrepared->bindParam(':link', $valuesTmp['link']);
			$this->updateEntryPrepared->bindParam(':date', $valuesTmp['date'], PDO::PARAM_INT);
			$this->updateEntryPrepared->bindParam(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
			if ($valuesTmp['is_read'] === null) {
				$this->updateEntryPrepared->bindValue(':is_read', null, PDO::PARAM_NULL);
			} else {
				$this->updateEntryPrepared->bindValue(':is_read', $valuesTmp['is_read'] ? 1 : 0, PDO::PARAM_INT);
			}
			if ($valuesTmp['is_favorite'] === null) {
				$this->updateEntryPrepared->bindValue(':is_favorite', null, PDO::PARAM_NULL);
			} else {
				$this->updateEntryPrepared->bindValue(':is_favorite', $valuesTmp['is_favorite'] ? 1 : 0, PDO::PARAM_INT);
			}
			$this->updateEntryPrepared->bindParam(':id_feed', $valuesTmp['id_feed'], PDO::PARAM_INT);
			$valuesTmp['tags'] = mb_strcut($valuesTmp['tags'], 0, 2048, '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 | JSON_UNESCAPED_UNICODE));

			if (static::hasNativeHex()) {
				$this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
			} else {
				$valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']);
				$this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hashBin']);
			}
		}

		if ($this->updateEntryPrepared && $this->updateEntryPrepared->execute()) {
			return true;
		} else {
			$info = $this->updateEntryPrepared == null ? $this->pdo->errorInfo() : $this->updateEntryPrepared->errorInfo();
			if ($this->autoUpdateDb($info)) {
				return $this->updateEntry($valuesTmp);
			}
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
				. ' while updating entry with GUID ' . $valuesTmp['guid'] . ' in feed ' . $valuesTmp['id_feed']);
			return false;
		}
	}

	/**
	 * Count the number of new entries in the temporary table (which have not yet been committed).
	 */
	public function countNewEntries(): int {
		$sql = <<<'SQL'
		SELECT COUNT(id) AS nb_entries FROM `_entrytmp`
		SQL;
		$res = $this->fetchColumn($sql, 0);
		return isset($res[0]) ? (int)$res[0] : -1;
	}

	/**
	 * Toggle favorite marker on one or more article
	 *
	 * @todo simplify the query by removing the str_repeat. I am pretty sure
	 * there is an other way to do that.
	 *
	 * @param numeric-string|array<numeric-string> $ids
	 * @return int|false
	 */
	public function markFavorite($ids, bool $is_favorite = true) {
		if (!is_array($ids)) {
			$ids = [$ids];
		}
		if (count($ids) < 1) {
			return 0;
		}
		FreshRSS_UserDAO::touch();
		if (count($ids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
			// Split a query with too many variables parameters
			$affected = 0;
			$idsChunks = array_chunk($ids, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
			foreach ($idsChunks as $idsChunk) {
				$affected += ($this->markFavorite($idsChunk, $is_favorite) ?: 0);
			}
			return $affected;
		}
		$sql = 'UPDATE `_entry` '
			. 'SET is_favorite=? '
			. 'WHERE id IN (' . str_repeat('?,', count($ids) - 1) . '?)';
		$values = [$is_favorite ? 1 : 0];
		$values = array_merge($values, $ids);
		$stm = $this->pdo->prepare($sql);
		if ($stm !== false && $stm->execute($values)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/**
	 * Update the unread article cache held on every feed details.
	 * Depending on the parameters, it updates the cache on one feed, on all
	 * feeds from one category or on all feeds.
	 */
	protected function updateCacheUnreads(?int $catId = null, ?int $feedId = null): bool {
		// Help MySQL/MariaDB's optimizer with the query plan:
		$useIndex = $this->pdo->dbType() === 'mysql' ? 'USE INDEX (entry_feed_read_index)' : '';

		$sql = <<<SQL
UPDATE `_feed`
SET `cache_nbUnreads`=(
	SELECT COUNT(*) AS nbUnreads FROM `_entry` e {$useIndex}
	WHERE e.id_feed=`_feed`.id AND e.is_read=0)
SQL;
		$hasWhere = false;
		$values = [];
		if ($feedId != null) {
			$sql .= ' WHERE';
			$hasWhere = true;
			$sql .= ' id=?';
			$values[] = $feedId;
		}
		if ($catId != null) {
			$sql .= $hasWhere ? ' AND' : ' WHERE';
			$hasWhere = true;
			$sql .= ' category=?';
			$values[] = $catId;
		}
		$stm = $this->pdo->prepare($sql);
		if ($stm !== false && $stm->execute($values)) {
			return true;
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/**
	 * Toggle the read marker on one or more article.
	 * Then the cache is updated.
	 *
	 * @param numeric-string|array<numeric-string> $ids
	 * @param bool $is_read
	 * @return int|false affected rows
	 */
	public function markRead($ids, bool $is_read = true) {
		if (is_array($ids)) {	//Many IDs at once
			if (count($ids) < 6) {	//Speed heuristics
				$affected = 0;
				foreach ($ids as $id) {
					$affected += ($this->markRead($id, $is_read) ?: 0);
				}
				return $affected;
			} elseif (count($ids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
				// Split a query with too many variables parameters
				$affected = 0;
				$idsChunks = array_chunk($ids, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
				foreach ($idsChunks as $idsChunk) {
					$affected += ($this->markRead($idsChunk, $is_read) ?: 0);
				}
				return $affected;
			}

			FreshRSS_UserDAO::touch();
			$sql = 'UPDATE `_entry` '
				 . 'SET is_read=? '
				 . 'WHERE id IN (' . str_repeat('?,', count($ids) - 1) . '?)';
			$values = [$is_read ? 1 : 0];
			$values = array_merge($values, $ids);
			$stm = $this->pdo->prepare($sql);
			if (!($stm && $stm->execute($values))) {
				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
				Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
				return false;
			}
			$affected = $stm->rowCount();
			if (($affected > 0) && (!$this->updateCacheUnreads(null, null))) {
				return false;
			}
			return $affected;
		} else {
			FreshRSS_UserDAO::touch();
			$sql = 'UPDATE `_entry` e INNER JOIN `_feed` f ON e.id_feed=f.id '
				 . 'SET e.is_read=?,'
				 . 'f.`cache_nbUnreads`=f.`cache_nbUnreads`' . ($is_read ? '-' : '+') . '1 '
				 . 'WHERE e.id=? AND e.is_read=?';
			$values = [$is_read ? 1 : 0, $ids, $is_read ? 0 : 1];
			$stm = $this->pdo->prepare($sql);
			if ($stm !== false && $stm->execute($values)) {
				return $stm->rowCount();
			} else {
				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
				Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
				return false;
			}
		}
	}

	/**
	 * Mark all entries as read depending on parameters.
	 * If $onlyFavorites is true, it is used when the user mark as read in
	 * the favorite pseudo-category.
	 * If $priorityMin is greater than 0, it is used when the user mark as
	 * read in the main feed pseudo-category.
	 * Then the cache is updated.
	 *
	 * If $idMax equals 0, a deprecated debug message is logged
	 *
	 * @param numeric-string $idMax fail safe article ID
	 * @return int|false affected rows
	 */
	public function markReadEntries(string $idMax = '0', bool $onlyFavorites = false, ?int $priorityMin = null, ?int $prioritMax = null,
		?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
		FreshRSS_UserDAO::touch();
		if ($idMax == '0') {
			$idMax = time() . '000000';
			Minz_Log::debug('Calling markReadEntries(0) is deprecated!');
		}

		$sql = 'UPDATE `_entry` SET is_read = ? WHERE is_read <> ? AND id <= ?';
		$values = [$is_read ? 1 : 0, $is_read ? 1 : 0, $idMax];
		if ($onlyFavorites) {
			$sql .= ' AND is_favorite=1';
		}
		if ($priorityMin !== null || $prioritMax !== null) {
			$sql .= ' AND id_feed IN (SELECT f.id FROM `_feed` f WHERE 1=1';
			if ($priorityMin !== null) {
				$sql .= ' AND f.priority >= ?';
				$values[] = $priorityMin;
			}
			if ($prioritMax !== null) {
				$sql .= ' AND f.priority < ?';
				$values[] = $prioritMax;
			}
			$sql .= ')';
		}

		[$searchValues, $search] = $this->sqlListEntriesWhere('', $filters, $state);

		$stm = $this->pdo->prepare($sql . $search);
		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
		$affected = $stm->rowCount();
		if (($affected > 0) && (!$this->updateCacheUnreads(null, null))) {
			return false;
		}
		return $affected;
	}

	/**
	 * Mark all the articles in a category as read.
	 * There is a fail safe to prevent to mark as read articles that are
	 * loaded during the mark as read action. Then the cache is updated.
	 *
	 * If $idMax equals 0, a deprecated debug message is logged
	 *
	 * @param int $id category ID
	 * @param numeric-string $idMax fail safe article ID
	 * @return int|false affected rows
	 */
	public function markReadCat(int $id, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
		FreshRSS_UserDAO::touch();
		if ($idMax == '0') {
			$idMax = time() . '000000';
			Minz_Log::debug('Calling markReadCat(0) is deprecated!');
		}

		$sql = <<<'SQL'
UPDATE `_entry`
SET is_read = ?
WHERE is_read <> ? AND id <= ?
AND id_feed IN (SELECT f.id FROM `_feed` f WHERE f.category=?)
SQL;
		$values = [$is_read ? 1 : 0, $is_read ? 1 : 0, $idMax, $id];

		[$searchValues, $search] = $this->sqlListEntriesWhere('', $filters, $state);

		$stm = $this->pdo->prepare($sql . $search);
		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
		$affected = $stm->rowCount();
		if (($affected > 0) && (!$this->updateCacheUnreads($id, null))) {
			return false;
		}
		return $affected;
	}

	/**
	 * Mark all the articles in a feed as read.
	 * There is a fail safe to prevent to mark as read articles that are
	 * loaded during the mark as read action. Then the cache is updated.
	 *
	 * If $idMax equals 0, a deprecated debug message is logged
	 *
	 * @param int $id_feed feed ID
	 * @param numeric-string $idMax fail safe article ID
	 * @return int|false affected rows
	 */
	public function markReadFeed(int $id_feed, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
		FreshRSS_UserDAO::touch();
		if ($idMax == '0') {
			$idMax = time() . '000000';
			Minz_Log::debug('Calling markReadFeed(0) is deprecated!');
		}
		$hadTransaction = $this->pdo->inTransaction();
		if (!$hadTransaction) {
			$this->pdo->beginTransaction();
		}

		$sql = 'UPDATE `_entry` '
			 . 'SET is_read=? '
			 . 'WHERE id_feed=? AND is_read <> ? AND id <= ?';
		$values = [$is_read ? 1 : 0, $id_feed, $is_read ? 1 : 0, $idMax];

		[$searchValues, $search] = $this->sqlListEntriesWhere('', $filters, $state);

		$stm = $this->pdo->prepare($sql . $search);
		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info) . ' with SQL: ' . $sql . $search);
			$this->pdo->rollBack();
			return false;
		}
		$affected = $stm->rowCount();

		if ($affected > 0) {
			$sql = 'UPDATE `_feed` '
				 . 'SET `cache_nbUnreads`=`cache_nbUnreads`-' . $affected
				 . ' WHERE id=:id';
			$stm = $this->pdo->prepare($sql);
			if (!($stm !== false &&
				$stm->bindParam(':id', $id_feed, PDO::PARAM_INT) &&
				$stm->execute())) {
				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
				Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
				$this->pdo->rollBack();
				return false;
			}
		}

		if (!$hadTransaction) {
			$this->pdo->commit();
		}
		return $affected;
	}

	/**
	 * Mark all the articles in a tag as read.
	 * @param int $id tag ID, or empty for targeting any tag
	 * @param numeric-string $idMax max article ID
	 * @return int|false affected rows
	 */
	public function markReadTag(int $id = 0, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null,
		int $state = 0, bool $is_read = true) {
		FreshRSS_UserDAO::touch();
		if ($idMax == '0') {
			$idMax = time() . '000000';
			Minz_Log::debug('Calling markReadTag(0) is deprecated!');
		}

		$sql = 'UPDATE `_entry` e INNER JOIN `_entrytag` et ON et.id_entry = e.id '
			 . 'SET e.is_read = ? '
			 . 'WHERE '
			 . ($id == 0 ? '' : 'et.id_tag = ? AND ')
			 . 'e.is_read <> ? AND e.id <= ?';
		$values = [$is_read ? 1 : 0];
		if ($id != 0) {
			$values[] = $id;
		}
		$values[] = $is_read ? 1 : 0;
		$values[] = $idMax;

		[$searchValues, $search] = $this->sqlListEntriesWhere('e.', $filters, $state);

		$stm = $this->pdo->prepare($sql . $search);
		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
		$affected = $stm->rowCount();
		if (($affected > 0) && (!$this->updateCacheUnreads(null, null))) {
			return false;
		}
		return $affected;
	}

	/**
	 * Remember to call updateCachedValues($id_feed) or updateCachedValues() just after.
	 * @param array<string,bool|int|string> $options
	 * @return int|false
	 */
	public function cleanOldEntries(int $id_feed, array $options = []) {
		$sql = 'DELETE FROM `_entry` WHERE id_feed = :id_feed1';	//No alias for MySQL / MariaDB
		$params = [];
		$params[':id_feed1'] = $id_feed;

		//==Exclusions==
		if (!empty($options['keep_favourites'])) {
			$sql .= ' AND is_favorite = 0';
		}
		if (!empty($options['keep_unreads'])) {
			$sql .= ' AND is_read = 1';
		}
		if (!empty($options['keep_labels'])) {
			$sql .= ' AND NOT EXISTS (SELECT 1 FROM `_entrytag` WHERE id_entry = id)';
		}
		if (!empty($options['keep_min']) && $options['keep_min'] > 0) {
			//Double SELECT for MySQL workaround ERROR 1093 (HY000)
			$sql .= ' AND `lastSeen` < (SELECT `lastSeen`'
				. ' FROM (SELECT e2.`lastSeen` FROM `_entry` e2 WHERE e2.id_feed = :id_feed2'
				. ' ORDER BY e2.`lastSeen` DESC LIMIT 1 OFFSET :keep_min) last_seen2)';
			$params[':id_feed2'] = $id_feed;
			$params[':keep_min'] = (int)$options['keep_min'];
		}
		//Keep at least the articles seen at the last refresh
		$sql .= ' AND `lastSeen` < (SELECT maxlastseen'
			. ' FROM (SELECT MAX(e3.`lastSeen`) AS maxlastseen FROM `_entry` e3 WHERE e3.id_feed = :id_feed3) last_seen3)';
		$params[':id_feed3'] = $id_feed;

		//==Inclusions==
		$sql .= ' AND (1=0';
		if (!empty($options['keep_period']) && is_string($options['keep_period'])) {
			$sql .= ' OR `lastSeen` < :max_last_seen';
			$now = new DateTime('now');
			$now->sub(new DateInterval($options['keep_period']));
			$params[':max_last_seen'] = $now->format('U');
		}
		if (!empty($options['keep_max']) && $options['keep_max'] > 0) {
			$sql .= ' OR `lastSeen` <= (SELECT `lastSeen`'
				. ' FROM (SELECT e4.`lastSeen` FROM `_entry` e4 WHERE e4.id_feed = :id_feed4'
				. ' ORDER BY e4.`lastSeen` DESC LIMIT 1 OFFSET :keep_max) last_seen4)';
			$params[':id_feed4'] = $id_feed;
			$params[':keep_max'] = (int)$options['keep_max'];
		}
		$sql .= ')';

		$stm = $this->pdo->prepare($sql);

		if ($stm !== false && $stm->execute($params)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			if ($this->autoUpdateDb($info)) {
				return $this->cleanOldEntries($id_feed, $options);
			}
			Minz_Log::error(__method__ . ' error:' . json_encode($info));
			return false;
		}
	}

	/** @return Traversable<array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'lastSeen':int,
	 *		'hash':string,'is_read':bool,'is_favorite':bool,'id_feed':int,'tags':string,'attributes':?string}> */
	public function selectAll(?int $limit = null): Traversable {
		$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
		$hash = static::sqlHexEncode('hash');
		$sql = <<<SQL
SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, {$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes
FROM `_entry`
SQL;
		if (is_int($limit) && $limit >= 0) {
			$sql .= ' ORDER BY id DESC LIMIT ' . $limit;
		}
		$stm = $this->pdo->query($sql);
		if ($stm != false) {
			while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
				/** @var array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'lastSeen':int,
				 *	'hash':string,'is_read':bool,'is_favorite':bool,'id_feed':int,'tags':string,'attributes':?string} $row */
				yield $row;
			}
		} else {
			$info = $this->pdo->errorInfo();
			if ($this->autoUpdateDb($info)) {
				yield from $this->selectAll();
			} else {
				Minz_Log::error(__method__ . ' error: ' . json_encode($info));
			}
		}
	}

	public function searchByGuid(int $id_feed, string $guid): ?FreshRSS_Entry {
		$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
		$hash = static::sqlHexEncode('hash');
		$sql = <<<SQL
SELECT id, guid, title, author, link, date, is_read, is_favorite, {$hash} AS hash, id_feed, tags, attributes, {$content}
FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid
SQL;
		$res = $this->fetchAssoc($sql, [':id_feed' => $id_feed, ':guid' => $guid]);
		/** @var array<array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
		 *		'is_read':int,'is_favorite':int,'tags':string,'attributes':?string}> $res */
		return isset($res[0]) ? FreshRSS_Entry::fromArray($res[0]) : null;
	}

	public function searchById(string $id): ?FreshRSS_Entry {
		$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
		$hash = static::sqlHexEncode('hash');
		$sql = <<<SQL
SELECT id, guid, title, author, link, date, is_read, is_favorite, {$hash} AS hash, id_feed, tags, attributes, {$content}
FROM `_entry` WHERE id=:id
SQL;
		$res = $this->fetchAssoc($sql, [':id' => $id]);
		/** @var array<array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
		 *		'is_read':int,'is_favorite':int,'tags':string,'attributes':?string}> $res */
		return isset($res[0]) ? FreshRSS_Entry::fromArray($res[0]) : null;
	}

	public function searchIdByGuid(int $id_feed, string $guid): ?string {
		$sql = 'SELECT id FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid';
		$res = $this->fetchColumn($sql, 0, [':id_feed' => $id_feed, ':guid' => $guid]);
		return empty($res[0]) ? null : (string)($res[0]);
	}

	/** @return array{0:array<int|string>,1:string} */
	public static function sqlBooleanSearch(string $alias, FreshRSS_BooleanSearch $filters, int $level = 0): array {
		$search = '';
		$values = [];

		$isOpen = false;
		foreach ($filters->searches() as $filter) {
			if ($filter == null) {
				continue;
			}
			if ($filter instanceof FreshRSS_BooleanSearch) {
				// BooleanSearches are combined by AND (default) or OR (special case) operator and are recursive
				[$filterValues, $filterSearch] = self::sqlBooleanSearch($alias, $filter, $level + 1);
				$filterSearch = trim($filterSearch);

				if ($filterSearch !== '') {
					if ($search !== '') {
						$search .= $filter->operator();
					} elseif (in_array($filter->operator(), ['AND NOT', 'OR NOT'], true)) {
						// Special case if we start with a negation (there is already the default AND before)
						$search .= ' NOT';
					}
					$search .= ' (' . $filterSearch . ') ';
					$values = array_merge($values, $filterValues);
				}
				continue;
			}
			// Searches are combined by OR and are not recursive
			$sub_search = '';
			if ($filter->getEntryIds() !== null) {
				$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() !== null) {
				$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() !== null) {
				$sub_search .= 'AND ' . $alias . 'id >= ? ';
				$values[] = "{$filter->getMinDate()}000000";
			}
			if ($filter->getMaxDate() !== null) {
				$sub_search .= 'AND ' . $alias . 'id <= ? ';
				$values[] = "{$filter->getMaxDate()}000000";
			}
			if ($filter->getMinPubdate() !== null) {
				$sub_search .= 'AND ' . $alias . 'date >= ? ';
				$values[] = $filter->getMinPubdate();
			}
			if ($filter->getMaxPubdate() !== null) {
				$sub_search .= 'AND ' . $alias . 'date <= ? ';
				$values[] = $filter->getMaxPubdate();
			}

			//Negation of date intervals must be combined by OR
			if ($filter->getNotMinDate() !== null || $filter->getNotMaxDate() !== null) {
				$sub_search .= 'AND (';
				if ($filter->getNotMinDate() !== null) {
					$sub_search .= $alias . 'id < ?';
					$values[] = "{$filter->getNotMinDate()}000000";
					if ($filter->getNotMaxDate()) {
						$sub_search .= ' OR ';
					}
				}
				if ($filter->getNotMaxDate() !== null) {
					$sub_search .= $alias . 'id > ?';
					$values[] = "{$filter->getNotMaxDate()}000000";
				}
				$sub_search .= ') ';
			}
			if ($filter->getNotMinPubdate() !== null || $filter->getNotMaxPubdate() !== null) {
				$sub_search .= 'AND (';
				if ($filter->getNotMinPubdate() !== null) {
					$sub_search .= $alias . 'date < ?';
					$values[] = $filter->getNotMinPubdate();
					if ($filter->getNotMaxPubdate()) {
						$sub_search .= ' OR ';
					}
				}
				if ($filter->getNotMaxPubdate() !== null) {
					$sub_search .= $alias . 'date > ?';
					$values[] = $filter->getNotMaxPubdate();
				}
				$sub_search .= ') ';
			}

			if ($filter->getFeedIds() !== null) {
				$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() !== null) {
				$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() !== null) {
				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() !== null) {
				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() !== null) {
				$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() !== null) {
				$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() !== null) {
				foreach ($filter->getAuthor() as $author) {
					$sub_search .= 'AND ' . $alias . 'author LIKE ? ';
					$values[] = "%{$author}%";
				}
			}
			if ($filter->getIntitle() !== null) {
				foreach ($filter->getIntitle() as $title) {
					$sub_search .= 'AND ' . $alias . 'title LIKE ? ';
					$values[] = "%{$title}%";
				}
			}
			if ($filter->getTags() !== null) {
				foreach ($filter->getTags() as $tag) {
					$sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' LIKE ? ';
					$values[] = "%{$tag} #%";
				}
			}
			if ($filter->getInurl() !== null) {
				foreach ($filter->getInurl() as $url) {
					$sub_search .= 'AND ' . $alias . 'link LIKE ? ';
					$values[] = "%{$url}%";
				}
			}

			if ($filter->getNotAuthor() !== null) {
				foreach ($filter->getNotAuthor() as $author) {
					$sub_search .= 'AND ' . $alias . 'author NOT LIKE ? ';
					$values[] = "%{$author}%";
				}
			}
			if ($filter->getNotIntitle() !== null) {
				foreach ($filter->getNotIntitle() as $title) {
					$sub_search .= 'AND ' . $alias . 'title NOT LIKE ? ';
					$values[] = "%{$title}%";
				}
			}
			if ($filter->getNotTags() !== null) {
				foreach ($filter->getNotTags() as $tag) {
					$sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' NOT LIKE ? ';
					$values[] = "%{$tag} #%";
				}
			}
			if ($filter->getNotInurl() !== null) {
				foreach ($filter->getNotInurl() as $url) {
					$sub_search .= 'AND ' . $alias . 'link NOT LIKE ? ';
					$values[] = "%{$url}%";
				}
			}

			if ($filter->getSearch() !== null) {
				foreach ($filter->getSearch() as $search_value) {
					if (static::isCompressed()) {	// MySQL-only
						$sub_search .= 'AND CONCAT(' . $alias . 'title, UNCOMPRESS(' . $alias . 'content_bin)) LIKE ? ';
						$values[] = "%{$search_value}%";
					} else {
						$sub_search .= 'AND (' . $alias . 'title LIKE ? OR ' . $alias . 'content LIKE ?) ';
						$values[] = "%{$search_value}%";
						$values[] = "%{$search_value}%";
					}
				}
			}
			if ($filter->getNotSearch() !== null) {
				foreach ($filter->getNotSearch() as $search_value) {
					if (static::isCompressed()) {	// MySQL-only
						$sub_search .= 'AND CONCAT(' . $alias . 'title, UNCOMPRESS(' . $alias . 'content_bin)) NOT LIKE ? ';
						$values[] = "%{$search_value}%";
					} else {
						$sub_search .= 'AND ' . $alias . 'title NOT LIKE ? AND ' . $alias . 'content NOT LIKE ? ';
						$values[] = "%{$search_value}%";
						$values[] = "%{$search_value}%";
					}
				}
			}

			if ($sub_search != '') {
				if ($isOpen) {
					$search .= ' OR ';
				} else {
					$isOpen = true;
				}
				// Remove superfluous leading 'AND '
				$search .= '(' . substr($sub_search, 4) . ')';
			}
		}

		return [ $values, $search ];
	}

	/**
	 * @param 'ASC'|'DESC' $order
	 * @return array{0:array<int|string>,1:string}
	 * @throws FreshRSS_EntriesGetter_Exception
	 */
	protected function sqlListEntriesWhere(string $alias = '', ?FreshRSS_BooleanSearch $filters = null,
			int $state = FreshRSS_Entry::STATE_ALL,
			string $order = 'DESC', string $firstId = '', int $date_min = 0): array {
		$search = ' ';
		$values = [];
		if ($state & FreshRSS_Entry::STATE_NOT_READ) {
			if (!($state & FreshRSS_Entry::STATE_READ)) {
				$search .= 'AND ' . $alias . 'is_read=0 ';
			}
		} elseif ($state & FreshRSS_Entry::STATE_READ) {
			$search .= 'AND ' . $alias . 'is_read=1 ';
		}
		if ($state & FreshRSS_Entry::STATE_FAVORITE) {
			if (!($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) {
				$search .= 'AND ' . $alias . 'is_favorite=1 ';
			}
		} elseif ($state & FreshRSS_Entry::STATE_NOT_FAVORITE) {
			$search .= 'AND ' . $alias . 'is_favorite=0 ';
		}

		switch ($order) {
			case 'DESC':
			case 'ASC':
				break;
			default:
				throw new FreshRSS_EntriesGetter_Exception('Bad order in Entry->listByType: [' . $order . ']!');
		}
		if ($firstId !== '') {
			$search .= 'AND ' . $alias . 'id ' . ($order === 'DESC' ? '<=' : '>=') . ' ? ';
			$values[] = $firstId;
		}
		if ($date_min > 0) {
			$search .= 'AND ' . $alias . 'id >= ? ';
			$values[] = $date_min . '000000';
		}
		if ($filters && count($filters->searches()) > 0) {
			[$filterValues, $filterSearch] = self::sqlBooleanSearch($alias, $filters);
			$filterSearch = trim($filterSearch);
			if ($filterSearch !== '') {
				$search .= 'AND (' . $filterSearch . ') ';
				$values = array_merge($values, $filterValues);
			}
		}
		return [$values, $search];
	}

	/**
	 * @phpstan-param 'a'|'A'|'i'|'s'|'S'|'c'|'f'|'t'|'T'|'ST' $type
	 * @param int $id category/feed/tag ID
	 * @param 'ASC'|'DESC' $order
	 * @return array{0:array<int|string>,1:string}
	 * @throws FreshRSS_EntriesGetter_Exception
	 */
	private function sqlListWhere(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
			string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null,
			int $date_min = 0): array {
		if (!$state) {
			$state = FreshRSS_Entry::STATE_ALL;
		}
		$where = '';
		$values = [];
		switch ($type) {
			case 'a':	//All PRIORITY_MAIN_STREAM
				$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_MAIN_STREAM . ' ';
				break;
			case 'A':	//All except PRIORITY_ARCHIVED
				$where .= 'f.priority > ' . FreshRSS_Feed::PRIORITY_ARCHIVED . ' ';
				break;
			case 'i':	//Priority important feeds
				$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_IMPORTANT . ' ';
				break;
			case 's':	//Starred. Deprecated: use $state instead
				$where .= 'f.priority > ' . FreshRSS_Feed::PRIORITY_ARCHIVED . ' ';
				$where .= 'AND e.is_favorite=1 ';
				break;
			case 'S':	//Starred
				$where .= 'e.is_favorite=1 ';
				break;
			case 'c':	//Category
				$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_CATEGORY . ' ';
				$where .= 'AND f.category=? ';
				$values[] = $id;
				break;
			case 'f':	//Feed
				$where .= 'e.id_feed=? ';
				$values[] = $id;
				break;
			case 't':	//Tag (label)
				$where .= 'et.id_tag=? ';
				$values[] = $id;
				break;
			case 'T':	//Any tag (label)
				$where .= '1=1 ';
				break;
			case 'ST':	//Starred or tagged (label)
				$where .= 'e.is_favorite=1 OR EXISTS (SELECT et2.id_tag FROM `_entrytag` et2 WHERE et2.id_entry = e.id) ';
				break;
			default:
				throw new FreshRSS_EntriesGetter_Exception('Bad type in Entry->listByType: [' . $type . ']!');
		}

		[$searchValues, $search] = $this->sqlListEntriesWhere('e.', $filters, $state, $order, $firstId, $date_min);

		return [array_merge($values, $searchValues), 'SELECT '
			. ($type === 'T' ? 'DISTINCT ' : '')
			. 'e.id FROM `_entry` e '
			. 'INNER JOIN `_feed` f ON e.id_feed = f.id '
			. ($type === 't' || $type === 'T' ? 'INNER JOIN `_entrytag` et ON et.id_entry = e.id ' : '')
			. 'WHERE ' . $where
			. $search
			. 'ORDER BY e.id ' . $order
			. ($limit > 0 ? ' LIMIT ' . $limit : '')	// http://explainextended.com/2009/10/23/mysql-order-by-limit-performance-late-row-lookups/
			. ($offset > 0 ? ' OFFSET ' . $offset : '')
		];
	}

	/**
	 * @phpstan-param 'a'|'A'|'s'|'S'|'i'|'c'|'f'|'t'|'T'|'ST' $type
	 * @param 'ASC'|'DESC' $order
	 * @param int $id category/feed/tag ID
	 * @return PDOStatement|false
	 * @throws FreshRSS_EntriesGetter_Exception
	 */
	private function listWhereRaw(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
			string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null,
			int $date_min = 0) {
		[$values, $sql] = $this->sqlListWhere($type, $id, $state, $order, $limit, $offset, $firstId, $filters, $date_min);

		if ($order !== 'DESC' && $order !== 'ASC') {
			$order = 'DESC';
		}
		$content = static::isCompressed() ? 'UNCOMPRESS(e0.content_bin) AS content' : 'e0.content';
		$hash = static::sqlHexEncode('e0.hash');
		$sql = <<<SQL
SELECT e0.id, e0.guid, e0.title, e0.author, {$content}, e0.link, e0.date, {$hash} AS hash, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags, e0.attributes
FROM `_entry` e0
INNER JOIN ({$sql}) e2 ON e2.id=e0.id
ORDER BY e0.id {$order}
SQL;
		$stm = $this->pdo->prepare($sql);
		if ($stm !== false && $stm->execute($values)) {
			return $stm;
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			if ($this->autoUpdateDb($info)) {
				return $this->listWhereRaw($type, $id, $state, $order, $limit, $offset, $firstId, $filters, $date_min);
			}
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/**
	 * @phpstan-param 'a'|'A'|'s'|'S'|'i'|'c'|'f'|'t'|'T'|'ST' $type
	 * @param int $id category/feed/tag ID
	 * @param 'ASC'|'DESC' $order
	 * @return Traversable<FreshRSS_Entry>
	 * @throws FreshRSS_EntriesGetter_Exception
	 */
	public function listWhere(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
			string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '',
			?FreshRSS_BooleanSearch $filters = null, int $date_min = 0): Traversable {
		$stm = $this->listWhereRaw($type, $id, $state, $order, $limit, $offset, $firstId, $filters, $date_min);
		if ($stm) {
			while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
				if (is_array($row)) {
					/** @var array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
					 *		'hash':string,'is_read':int,'is_favorite':int,'tags':string,'attributes'?:?string} $row */
					yield FreshRSS_Entry::fromArray($row);
				}
			}
		}
	}

	/**
	 * @param array<numeric-string> $ids
	 * @param 'ASC'|'DESC' $order
	 * @return Traversable<FreshRSS_Entry>
	 */
	public function listByIds(array $ids, string $order = 'DESC'): Traversable {
		if (count($ids) < 1) {
			return;
		}
		if (count($ids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
			// Split a query with too many variables parameters
			$idsChunks = array_chunk($ids, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
			foreach ($idsChunks as $idsChunk) {
				foreach ($this->listByIds($idsChunk, $order) as $entry) {
					yield $entry;
				}
			}
			return;
		}
		if ($order !== 'DESC' && $order !== 'ASC') {
			$order = 'DESC';
		}
		$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
		$hash = static::sqlHexEncode('hash');
		$repeats = str_repeat('?,', count($ids) - 1) . '?';
		$sql = <<<SQL
SELECT id, guid, title, author, link, date, {$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes, {$content}
FROM `_entry`
WHERE id IN ({$repeats})
ORDER BY id {$order}
SQL;
		$stm = $this->pdo->prepare($sql);
		if ($stm === false || !$stm->execute($ids)) {
			return;
		}
		while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
			if (is_array($row)) {
				/** @var array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
				 *		'hash':string,'is_read':int,'is_favorite':int,'tags':string,'attributes':?string} $row */
				yield FreshRSS_Entry::fromArray($row);
			}
		}
	}

	/**
	 * @phpstan-param 'a'|'A'|'s'|'S'|'c'|'f'|'t'|'T'|'ST' $type
	 * @param int $id category/feed/tag ID
	 * @param 'ASC'|'DESC' $order
	 * @return array<numeric-string>|null
	 * @throws FreshRSS_EntriesGetter_Exception
	 */
	public function listIdsWhere(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
		string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null): ?array {

		[$values, $sql] = $this->sqlListWhere($type, $id, $state, $order, $limit, $offset, $firstId, $filters);
		$stm = $this->pdo->prepare($sql);
		if ($stm !== false && $stm->execute($values) && ($res = $stm->fetchAll(PDO::FETCH_COLUMN, 0)) !== false) {
			/** @var array<numeric-string> $res */
			return $res;
		}
		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
		return null;
	}

	/**
	 * @param array<string> $guids
	 * @return array<string>|false
	 */
	public function listHashForFeedGuids(int $id_feed, array $guids) {
		$result = [];
		if (count($guids) < 1) {
			return $result;
		} elseif (count($guids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
			// Split a query with too many variables parameters
			$guidsChunks = array_chunk($guids, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
			foreach ($guidsChunks as $guidsChunk) {
				$result += $this->listHashForFeedGuids($id_feed, $guidsChunk);
			}
			return $result;
		}
		$guids = array_unique($guids);
		$sql = 'SELECT guid, ' . static::sqlHexEncode('hash') .
			' AS hex_hash FROM `_entry` WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1) . '?)';
		$stm = $this->pdo->prepare($sql);
		$values = [$id_feed];
		$values = array_merge($values, $guids);
		if ($stm !== false && $stm->execute($values)) {
			$rows = $stm->fetchAll(PDO::FETCH_ASSOC);
			foreach ($rows as $row) {
				$result[$row['guid']] = $row['hex_hash'];
			}
			return $result;
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			if ($this->autoUpdateDb($info)) {
				return $this->listHashForFeedGuids($id_feed, $guids);
			}
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
				. ' while querying feed ' . $id_feed);
			return false;
		}
	}

	/**
	 * @param array<string> $guids
	 * @return int|false The number of affected entries, or false if error
	 */
	public function updateLastSeen(int $id_feed, array $guids, int $mtime = 0) {
		if (count($guids) < 1) {
			return 0;
		} elseif (count($guids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
			// Split a query with too many variables parameters
			$affected = 0;
			$guidsChunks = array_chunk($guids, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
			foreach ($guidsChunks as $guidsChunk) {
				$affected += ($this->updateLastSeen($id_feed, $guidsChunk, $mtime) ?: 0);
			}
			return $affected;
		}
		$sql = 'UPDATE `_entry` SET `lastSeen`=? WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1) . '?)';
		$stm = $this->pdo->prepare($sql);
		if ($mtime <= 0) {
			$mtime = time();
		}
		$values = [$mtime, $id_feed];
		$values = array_merge($values, $guids);
		if ($stm !== false && $stm->execute($values)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			if ($this->autoUpdateDb($info)) {
				return $this->updateLastSeen($id_feed, $guids);
			}
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
				. ' while updating feed ' . $id_feed);
			return false;
		}
	}

	/**
	 * Update (touch) the last seen attribute of the latest entries of a given feed.
	 * Useful when a feed is unchanged / cached.
	 * To be performed just before {@see FreshRSS_FeedDAO::updateLastUpdate()}
	 * @return int|false The number of affected entries, or false in case of error
	 */
	public function updateLastSeenUnchanged(int $id_feed, int $mtime = 0) {
		$sql = <<<'SQL'
UPDATE `_entry` SET `lastSeen` = :mtime
WHERE id_feed = :id_feed1 AND `lastSeen` = (
	SELECT `lastUpdate` FROM `_feed` f
	WHERE f.id = :id_feed2
)
SQL;
		$stm = $this->pdo->prepare($sql);
		if ($mtime <= 0) {
			$mtime = time();
		}
		if ($stm !== false &&
			$stm->bindValue(':mtime', $mtime, PDO::PARAM_INT) &&
			$stm->bindValue(':id_feed1', $id_feed, PDO::PARAM_INT) &&
			$stm->bindValue(':id_feed2', $id_feed, PDO::PARAM_INT) &&
			$stm->execute()) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info) . ' while updating feed ' . $id_feed);
			return false;
		}
	}

	/** @return array<string,int> */
	public function countUnreadRead(): array {
		$sql = <<<'SQL'
SELECT COUNT(e.id) AS count FROM `_entry` e
	INNER JOIN `_feed` f ON e.id_feed=f.id
	WHERE f.priority > 0
UNION
SELECT COUNT(e.id) AS count FROM `_entry` e
	INNER JOIN `_feed` f ON e.id_feed=f.id
	WHERE f.priority > 0 AND e.is_read=0
SQL;
		$res = $this->fetchColumn($sql, 0);
		if ($res === null) {
			return ['all' => -1, 'unread' => -1, 'read' => -1];
		}
		rsort($res);
		$all = (int)($res[0] ?? 0);
		$unread = (int)($res[1] ?? 0);
		return ['all' => $all, 'unread' => $unread, 'read' => $all - $unread];
	}

	public function count(?int $minPriority = null): int {
		$sql = 'SELECT COUNT(e.id) AS count FROM `_entry` e';
		$values = [];
		if ($minPriority !== null) {
			$sql .= ' INNER JOIN `_feed` f ON e.id_feed=f.id';
			$sql .= ' WHERE f.priority > :priority';
			$values[':priority'] = $minPriority;
		}
		$res = $this->fetchColumn($sql, 0, $values);
		return isset($res[0]) ? (int)($res[0]) : -1;
	}

	public function countNotRead(?int $minPriority = null): int {
		$sql = 'SELECT COUNT(e.id) AS count FROM `_entry` e';
		if ($minPriority !== null) {
			$sql .= ' INNER JOIN `_feed` f ON e.id_feed=f.id';
		}
		$sql .= ' WHERE e.is_read=0';
		$values = [];
		if ($minPriority !== null) {
			$sql .= ' AND f.priority > :priority';
			$values[':priority'] = $minPriority;
		}
		$res = $this->fetchColumn($sql, 0, $values);
		return isset($res[0]) ? (int)($res[0]) : -1;
	}

	/** @return array{'all':int,'read':int,'unread':int} */
	public function countUnreadReadFavorites(): array {
		$sql = <<<'SQL'
SELECT c FROM (
	SELECT COUNT(e1.id) AS c, 1 AS o
		FROM `_entry` AS e1
		JOIN `_feed` AS f1 ON e1.id_feed = f1.id
		WHERE e1.is_favorite = 1
		AND f1.priority >= :priority1
	UNION
	SELECT COUNT(e2.id) AS c, 2 AS o
		FROM `_entry` AS e2
		JOIN `_feed` AS f2 ON e2.id_feed = f2.id
		WHERE e2.is_favorite = 1
		AND e2.is_read = 0 AND f2.priority >= :priority2
	) u
ORDER BY o
SQL;
		//Binding a value more than once is not standard and does not work with native prepared statements (e.g. MySQL) https://bugs.php.net/bug.php?id=40417
		$res = $this->fetchColumn($sql, 0, [
			':priority1' => FreshRSS_Feed::PRIORITY_CATEGORY,
			':priority2' => FreshRSS_Feed::PRIORITY_CATEGORY,
		]);
		if ($res === null) {
			return ['all' => -1, 'unread' => -1, 'read' => -1];
		}

		rsort($res);
		$all = (int)($res[0] ?? 0);
		$unread = (int)($res[1] ?? 0);
		return ['all' => $all, 'unread' => $unread, 'read' => $all - $unread];
	}
}
EntryDAOPGSQL.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/EntryDAOPGSQL.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {

	#[\Override]
	public static function hasNativeHex(): bool {
		return true;
	}

	#[\Override]
	public static function sqlHexDecode(string $x): string {
		return 'decode(' . $x . ", 'hex')";
	}

	#[\Override]
	public static function sqlHexEncode(string $x): string {
		return 'encode(' . $x . ", 'hex')";
	}

	#[\Override]
	public static function sqlIgnoreConflict(string $sql): string {
		return rtrim($sql, ' ;') . ' ON CONFLICT DO NOTHING';
	}

	/** @param array<string|int> $errorInfo */
	#[\Override]
	protected function autoUpdateDb(array $errorInfo): bool {
		if (isset($errorInfo[0])) {
			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
				$errorLines = explode("\n", (string)$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);
					}
				}
			}
		}
		return false;
	}

	#[\Override]
	public function commitNewEntries(): bool {
		//TODO: Update to PostgreSQL 9.5+ syntax with ON CONFLICT DO NOTHING
		$sql = 'DO $$
DECLARE
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, 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, attributes
			FROM `_entrytmp` AS etmp
			WHERE NOT EXISTS (
				SELECT 1 FROM `_entry` AS ereal
				WHERE (etmp.id = ereal.id) OR (etmp.id_feed = ereal.id_feed AND etmp.guid = ereal.guid))
			ORDER BY date, id);
	DELETE FROM `_entrytmp` WHERE id <= maxrank;
END $$;';
		$hadTransaction = $this->pdo->inTransaction();
		if (!$hadTransaction) {
			$this->pdo->beginTransaction();
		}
		$result = $this->pdo->exec($sql) !== false;
		if (!$hadTransaction) {
			$this->pdo->commit();
		}
		return $result;
	}
}
EntryDAOSQLite.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/EntryDAOSQLite.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {

	#[\Override]
	public static function isCompressed(): bool {
		return false;
	}

	#[\Override]
	public static function hasNativeHex(): bool {
		return false;
	}

	#[\Override]
	protected static function sqlConcat(string $s1, string $s2): string {
		return $s1 . '||' . $s2;
	}

	#[\Override]
	public static function sqlHexDecode(string $x): string {
		return $x;
	}

	#[\Override]
	public static function sqlIgnoreConflict(string $sql): string {
		return str_replace('INSERT INTO ', 'INSERT OR IGNORE INTO ', $sql);
	}

	/** @param array<string|int> $errorInfo */
	#[\Override]
	protected function autoUpdateDb(array $errorInfo): bool {
		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, true)) {
					return $this->addColumn($column);
				}
			}
		}
		return false;
	}

	#[\Override]
	public function commitNewEntries(): bool {
		$sql = <<<'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, 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, 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, attributes
	FROM `tmp`
	ORDER BY date, id;
DELETE FROM `_entrytmp` WHERE id <= (SELECT MAX(id) FROM `tmp`);
DROP TABLE IF EXISTS `tmp`;
SQL;
		$hadTransaction = $this->pdo->inTransaction();
		if (!$hadTransaction) {
			$this->pdo->beginTransaction();
		}
		$result = $this->pdo->exec($sql) !== false;
		if (!$result) {
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
		}
		if (!$hadTransaction) {
			$this->pdo->commit();
		}
		return $result;
	}

	/**
	 * Toggle the read marker on one or more article.
	 * Then the cache is updated.
	 *
	 * @param string|array<string> $ids
	 * @param bool $is_read
	 * @return int|false affected rows
	 */
	#[\Override]
	public function markRead($ids, bool $is_read = true) {
		if (is_array($ids)) {	//Many IDs at once (used by API)
			//if (true) {	//Speed heuristics	//TODO: Not implemented yet for SQLite (so always call IDs one by one)
			$affected = 0;
			foreach ($ids as $id) {
				$affected += ($this->markRead($id, $is_read) ?: 0);
			}
			return $affected;
			//}
		} else {
			FreshRSS_UserDAO::touch();
			$this->pdo->beginTransaction();
			$sql = 'UPDATE `_entry` SET is_read=? WHERE id=? AND is_read=?';
			$values = [$is_read ? 1 : 0, $ids, $is_read ? 0 : 1];
			$stm = $this->pdo->prepare($sql);
			if (!($stm && $stm->execute($values))) {
				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
				Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
				$this->pdo->rollBack();
				return false;
			}
			$affected = $stm->rowCount();
			if ($affected > 0) {
				$sql = 'UPDATE `_feed` SET `cache_nbUnreads`=`cache_nbUnreads`' . ($is_read ? '-' : '+') . '1 '
				 . 'WHERE id=(SELECT e.id_feed FROM `_entry` e WHERE e.id=?)';
				$values = [$ids];
				$stm = $this->pdo->prepare($sql);
				if (!($stm && $stm->execute($values))) {
					$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
					Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
					$this->pdo->rollBack();
					return false;
				}
			}
			$this->pdo->commit();
			return $affected;
		}
	}

	/**
	 * Mark all the articles in a tag as read.
	 * @param int $id tag ID, or empty for targeting any tag
	 * @param string $idMax max article ID
	 * @return int|false affected rows
	 */
	#[\Override]
	public function markReadTag($id = 0, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
		FreshRSS_UserDAO::touch();
		if ($idMax == 0) {
			$idMax = time() . '000000';
			Minz_Log::debug('Calling markReadTag(0) is deprecated!');
		}

		$sql = 'UPDATE `_entry` SET is_read = ? WHERE is_read <> ? AND id <= ? AND '
			 . 'id IN (SELECT et.id_entry FROM `_entrytag` et '
			 . ($id == 0 ? '' : 'WHERE et.id_tag = ?')
			 . ')';
		$values = [$is_read ? 1 : 0, $is_read ? 1 : 0, $idMax];
		if ($id != 0) {
			$values[] = $id;
		}

		[$searchValues, $search] = $this->sqlListEntriesWhere('e.', $filters, $state);

		$stm = $this->pdo->prepare($sql . $search);
		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
		$affected = $stm->rowCount();
		if (($affected > 0) && (!$this->updateCacheUnreads(null, null))) {
			return false;
		}
		return $affected;
	}
}
Factory.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Factory.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_Factory {

	/**
	 * @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
	 */
	public static function createUserDao(?string $username = null): FreshRSS_UserDAO {
		return new FreshRSS_UserDAO($username);
	}

	/**
	 * @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
	 */
	public static function createCategoryDao(?string $username = null): FreshRSS_CategoryDAO {
		switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
			case 'sqlite':
				return new FreshRSS_CategoryDAOSQLite($username);
			default:
				return new FreshRSS_CategoryDAO($username);
		}
	}

	/**
	 * @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
	 */
	public static function createFeedDao(?string $username = null): FreshRSS_FeedDAO {
		switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
			case 'sqlite':
				return new FreshRSS_FeedDAOSQLite($username);
			default:
				return new FreshRSS_FeedDAO($username);
		}
	}

	/**
	 * @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
	 */
	public static function createEntryDao(?string $username = null): FreshRSS_EntryDAO {
		switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
			case 'sqlite':
				return new FreshRSS_EntryDAOSQLite($username);
			case 'pgsql':
				return new FreshRSS_EntryDAOPGSQL($username);
			default:
				return new FreshRSS_EntryDAO($username);
		}
	}

	/**
	 * @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
	 */
	public static function createTagDao(?string $username = null): FreshRSS_TagDAO {
		switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
			case 'sqlite':
				return new FreshRSS_TagDAOSQLite($username);
			case 'pgsql':
				return new FreshRSS_TagDAOPGSQL($username);
			default:
				return new FreshRSS_TagDAO($username);
		}
	}

	/**
	 * @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
	 */
	public static function createStatsDAO(?string $username = null): FreshRSS_StatsDAO {
		switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
			case 'sqlite':
				return new FreshRSS_StatsDAOSQLite($username);
			case 'pgsql':
				return new FreshRSS_StatsDAOPGSQL($username);
			default:
				return new FreshRSS_StatsDAO($username);
		}
	}

	/**
	 * @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
	 */
	public static function createDatabaseDAO(?string $username = null): FreshRSS_DatabaseDAO {
		switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
			case 'sqlite':
				return new FreshRSS_DatabaseDAOSQLite($username);
			case 'pgsql':
				return new FreshRSS_DatabaseDAOPGSQL($username);
			default:
				return new FreshRSS_DatabaseDAO($username);
		}
	}
}
Feed.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Feed.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_Feed extends Minz_Model {
	use FreshRSS_AttributesTrait, FreshRSS_FilterActionsTrait;

	/**
	 * Normal RSS or Atom feed
	 * @var int
	 */
	public const KIND_RSS = 0;
	/**
	 * Invalid RSS or Atom feed
	 * @var int
	 */
	public const KIND_RSS_FORCED = 2;
	/**
	 * Normal HTML with XPath scraping
	 * @var int
	 */
	public const KIND_HTML_XPATH = 10;
	/**
	 * Normal XML with XPath scraping
	 * @var int
	 */
	public const KIND_XML_XPATH = 15;
	/**
	 * Normal JSON with XPath scraping
	 * @var int
	 */
	public const KIND_JSON_XPATH = 20;

	public const KIND_JSONFEED = 25;
	public const KIND_JSON_DOTNOTATION = 30;

	public const PRIORITY_IMPORTANT = 20;
	public const PRIORITY_MAIN_STREAM = 10;
	public const PRIORITY_CATEGORY = 0;
	public const PRIORITY_ARCHIVED = -10;

	public const TTL_DEFAULT = 0;

	public const ARCHIVING_RETENTION_COUNT_LIMIT = 10000;
	public const ARCHIVING_RETENTION_PERIOD = 'P3M';

	private int $id = 0;
	private string $url = '';
	private int $kind = 0;
	private int $categoryId = 0;
	private ?FreshRSS_Category $category = null;
	private int $nbEntries = -1;
	private int $nbNotRead = -1;
	private string $name = '';
	private string $website = '';
	private string $description = '';
	private int $lastUpdate = 0;
	private int $priority = self::PRIORITY_MAIN_STREAM;
	private string $pathEntries = '';
	private string $httpAuth = '';
	private bool $error = false;
	private int $ttl = self::TTL_DEFAULT;
	private bool $mute = false;
	private string $hash = '';
	private string $lockPath = '';
	private string $hubUrl = '';
	private string $selfUrl = '';

	/**
	 * @throws FreshRSS_BadUrl_Exception
	 */
	public function __construct(string $url, bool $validate = true) {
		if ($validate) {
			$this->_url($url);
		} else {
			$this->url = $url;
		}
	}

	public static function default(): FreshRSS_Feed {
		$f = new FreshRSS_Feed('http://example.net/', false);
		$f->faviconPrepare();
		return $f;
	}

	public function id(): int {
		return $this->id;
	}

	public function hash(): string {
		if ($this->hash == '') {
			$salt = FreshRSS_Context::systemConf()->salt;
			$this->hash = hash('crc32b', $salt . $this->url);
		}
		return $this->hash;
	}

	public function url(bool $includeCredentials = true): string {
		return $includeCredentials ? $this->url : SimplePie_Misc::url_remove_credentials($this->url);
	}
	public function selfUrl(): string {
		return $this->selfUrl;
	}
	public function kind(): int {
		return $this->kind;
	}
	public function hubUrl(): string {
		return $this->hubUrl;
	}

	public function category(): ?FreshRSS_Category {
		if ($this->category === null && $this->categoryId > 0) {
			$catDAO = FreshRSS_Factory::createCategoryDao();
			$this->category = $catDAO->searchById($this->categoryId);
		}
		return $this->category;
	}

	public function categoryId(): int {
		if ($this->category !== null) {
			return $this->category->id() ?: $this->categoryId;
		}
		return $this->categoryId;
	}

	/**
	 * @return array<FreshRSS_Entry>|null
	 * @deprecated
	 */
	public function entries(): ?array {
		Minz_Log::warning(__method__ . ' is deprecated since FreshRSS 1.16.1!');
		$simplePie = $this->load(false, true);
		return $simplePie == null ? [] : iterator_to_array($this->loadEntries($simplePie));
	}
	public function name(bool $raw = false): string {
		return $raw || $this->name != '' ? $this->name : (preg_replace('%^https?://(www[.])?%i', '', $this->url) ?? '');
	}
	/** @return string HTML-encoded URL of the Web site of the feed */
	public function website(): string {
		return $this->website;
	}
	public function description(): string {
		return $this->description;
	}
	public function lastUpdate(): int {
		return $this->lastUpdate;
	}
	public function priority(): int {
		return $this->priority;
	}
	/** @return string HTML-encoded CSS selector */
	public function pathEntries(): string {
		return $this->pathEntries;
	}
	/**
	 * @phpstan-return ($raw is true ? string : array{'username':string,'password':string})
	 * @return array{'username':string,'password':string}|string
	 */
	public function httpAuth(bool $raw = true) {
		if ($raw) {
			return $this->httpAuth;
		} else {
			$pos_colon = strpos($this->httpAuth, ':');
			if ($pos_colon !== false) {
				$user = substr($this->httpAuth, 0, $pos_colon);
				$pass = substr($this->httpAuth, $pos_colon + 1);
			} else {
				$user = '';
				$pass = '';
			}

			return [
				'username' => $user,
				'password' => $pass,
				];
		}
	}

	/** @return array<int,mixed> */
	public function curlOptions(): array {
		$curl_options = [];
		if ($this->httpAuth !== '') {
			$curl_options[CURLOPT_USERPWD] = htmlspecialchars_decode($this->httpAuth, ENT_QUOTES);
		}
		return $curl_options;
	}

	public function inError(): bool {
		return $this->error;
	}

	/**
	 * @param bool $raw true for database version combined with mute information, false otherwise
	 */
	public function ttl(bool $raw = false): int {
		if ($raw) {
			$ttl = $this->ttl;
			if ($this->mute && FreshRSS_Feed::TTL_DEFAULT === $ttl) {
				$ttl = FreshRSS_Context::userConf()->ttl_default;
			}
			return $ttl * ($this->mute ? -1 : 1);
		}
		if ($this->mute && $this->ttl === FreshRSS_Context::userConf()->ttl_default) {
			return FreshRSS_Feed::TTL_DEFAULT;
		}
		return $this->ttl;
	}

	public function mute(): bool {
		return $this->mute;
	}

	public function nbEntries(): int {
		if ($this->nbEntries < 0) {
			$feedDAO = FreshRSS_Factory::createFeedDao();
			$this->nbEntries = $feedDAO->countEntries($this->id());
		}

		return $this->nbEntries;
	}
	public function nbNotRead(): int {
		if ($this->nbNotRead < 0) {
			$feedDAO = FreshRSS_Factory::createFeedDao();
			$this->nbNotRead = $feedDAO->countNotRead($this->id());
		}

		return $this->nbNotRead;
	}

	public function faviconPrepare(): void {
		require_once(LIB_PATH . '/favicons.php');
		$url = $this->website;
		if ($url == '') {
			$url = $this->url;
		}
		$txt = FAVICONS_DIR . $this->hash() . '.txt';
		if (@file_get_contents($txt) !== $url) {
			file_put_contents($txt, $url);
		}
		if (FreshRSS_Context::$isCli) {
			$ico = FAVICONS_DIR . $this->hash() . '.ico';
			$ico_mtime = @filemtime($ico);
			$txt_mtime = @filemtime($txt);
			if ($txt_mtime != false &&
				($ico_mtime == false || $ico_mtime < $txt_mtime || ($ico_mtime < time() - (14 * 86400)))) {
				// no ico file or we should download a new one.
				$url = file_get_contents($txt);
				if ($url == false || !download_favicon($url, $ico)) {
					touch($ico);
				}
			}
		}
	}

	public static function faviconDelete(string $hash): void {
		$path = DATA_PATH . '/favicons/' . $hash;
		@unlink($path . '.ico');
		@unlink($path . '.txt');
	}
	public function favicon(): string {
		return Minz_Url::display('/f.php?' . $this->hash());
	}

	public function _id(int $value): void {
		$this->id = $value;
	}

	/**
	 * @throws FreshRSS_BadUrl_Exception
	 */
	public function _url(string $value, bool $validate = true): void {
		$this->hash = '';
		$url = $value;
		if ($validate) {
			$url = checkUrl($url);
		}
		if ($url == false) {
			throw new FreshRSS_BadUrl_Exception($value);
		}
		$this->url = $url;
	}

	public function _kind(int $value): void {
		$this->kind = $value;
	}

	public function _category(?FreshRSS_Category $cat): void {
		$this->category = $cat;
		$this->categoryId = $this->category == null ? 0 : $this->category->id();
	}

	/** @param int|string $id */
	public function _categoryId($id): void {
		$this->category = null;
		$this->categoryId = (int)$id;
	}

	public function _name(string $value): void {
		$this->name = $value == '' ? '' : trim($value);
	}
	public function _website(string $value, bool $validate = true): void {
		if ($validate) {
			$value = checkUrl($value);
		}
		if ($value == false) {
			$value = '';
		}
		$this->website = $value;
	}
	public function _description(string $value): void {
		$this->description = $value == '' ? '' : $value;
	}
	public function _lastUpdate(int $value): void {
		$this->lastUpdate = $value;
	}
	public function _priority(int $value): void {
		$this->priority = $value;
	}
	/** @param string $value HTML-encoded CSS selector */
	public function _pathEntries(string $value): void {
		$this->pathEntries = $value;
	}
	public function _httpAuth(string $value): void {
		$this->httpAuth = $value;
	}
	/** @param bool|int $value */
	public function _error($value): void {
		$this->error = (bool)$value;
	}
	public function _mute(bool $value): void {
		$this->mute = $value;
	}
	public function _ttl(int $value): void {
		$value = min($value, 100_000_000);
		$this->ttl = abs($value);
		$this->mute = $value < self::TTL_DEFAULT;
	}

	public function _nbNotRead(int $value): void {
		$this->nbNotRead = $value;
	}
	public function _nbEntries(int $value): void {
		$this->nbEntries = $value;
	}

	/**
	 * @throws Minz_FileNotExistException
	 * @throws FreshRSS_Feed_Exception
	 */
	public function load(bool $loadDetails = false, bool $noCache = false): ?SimplePie {
		if ($this->url != '') {
			/**
			 * @throws Minz_FileNotExistException
			 */
			if (CACHE_PATH == '') {
				throw new Minz_FileNotExistException(
					'CACHE_PATH',
					Minz_Exception::ERROR
				);
			} else {
				$simplePie = customSimplePie($this->attributes(), $this->curlOptions());
				$url = htmlspecialchars_decode($this->url, ENT_QUOTES);
				if (substr($url, -11) === '#force_feed') {
					$simplePie->force_feed(true);
					$url = substr($url, 0, -11);
				}
				$simplePie->set_feed_url($url);
				if (!$loadDetails) {	//Only activates auto-discovery when adding a new feed
					$simplePie->set_autodiscovery_level(SIMPLEPIE_LOCATOR_NONE);
				}
				if ($this->attributeBoolean('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', $simplePie, $this);
				$mtime = $simplePie->init();

				if ((!$mtime) || $simplePie->error()) {
					$errorMessage = $simplePie->error();
					if (empty($errorMessage)) {
						$errorMessage = '';
					} elseif (is_array($errorMessage)) {
						$errorMessage = json_encode($errorMessage, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS) ?: '';
					}
					throw new FreshRSS_Feed_Exception(
						($errorMessage == '' ? 'Unknown error for feed' : $errorMessage) .
							' [' . $this->url . ']',
						$simplePie->status_code()
					);
				}

				$links = $simplePie->get_links('self');
				$this->selfUrl = empty($links[0]) ? '' : (checkUrl($links[0]) ?: '');
				$links = $simplePie->get_links('hub');
				$this->hubUrl = empty($links[0]) ? '' : (checkUrl($links[0]) ?: '');

				if ($loadDetails) {
					// si on a utilisé l’auto-discover, notre url va avoir changé
					$subscribe_url = $simplePie->subscribe_url(false) ?? '';

					if ($this->name(true) === '') {
						//HTML to HTML-PRE	//ENT_COMPAT except '&'
						$title = strtr(html_only_entity_decode($simplePie->get_title()), ['<' => '&lt;', '>' => '&gt;', '"' => '&quot;']);
						$this->_name($title == '' ? $this->url : $title);
					}
					if ($this->website() === '') {
						$this->_website(html_only_entity_decode($simplePie->get_link()));
					}
					if ($this->description() === '') {
						$this->_description(html_only_entity_decode($simplePie->get_description()));
					}
				} else {
					//The case of HTTP 301 Moved Permanently
					$subscribe_url = $simplePie->subscribe_url(true) ?? '';
				}

				$clean_url = SimplePie_Misc::url_remove_credentials($subscribe_url);
				if ($subscribe_url !== '' && $subscribe_url !== $url) {
					$this->_url($clean_url);
				}

				if (($mtime === true) || ($mtime > $this->lastUpdate) || $noCache) {
					//Minz_Log::debug('FreshRSS no cache ' . $mtime . ' > ' . $this->lastUpdate . ' for ' . $clean_url);
					return $simplePie;
				}
				//Minz_Log::debug('FreshRSS use cache for ' . $clean_url);
			}
		}
		return null;
	}

	/**
	 * @return array<string>
	 */
	public function loadGuids(SimplePie $simplePie): array {
		$hasUniqueGuids = true;
		$testGuids = [];
		$guids = [];
		$links = [];
		$hadBadGuids = $this->attributeBoolean('hasBadGuids');

		$items = $simplePie->get_items();
		if (empty($items)) {
			return $guids;
		}
		for ($i = count($items) - 1; $i >= 0; $i--) {
			$item = $items[$i];
			if ($item == null) {
				continue;
			}
			$guid = safe_ascii($item->get_id(false, false));
			$hasUniqueGuids &= empty($testGuids['_' . $guid]);
			$testGuids['_' . $guid] = true;
			$guids[] = $guid;
			$permalink = $item->get_permalink();
			if ($permalink != null) {
				$links[] = $permalink;
			}
		}

		if ($hadBadGuids != !$hasUniqueGuids) {
			if ($hadBadGuids) {
				Minz_Log::warning('Feed has invalid GUIDs: ' . $this->url);
			} else {
				Minz_Log::warning('Feed has valid GUIDs again: ' . $this->url);
			}
			$feedDAO = FreshRSS_Factory::createFeedDao();
			$feedDAO->updateFeedAttribute($this, 'hasBadGuids', !$hasUniqueGuids);
		}

		return $hasUniqueGuids ? $guids : $links;
	}

	/** @return Traversable<FreshRSS_Entry> */
	public function loadEntries(SimplePie $simplePie): Traversable {
		$hasBadGuids = $this->attributeBoolean('hasBadGuids');

		$items = $simplePie->get_items();
		if (empty($items)) {
			return;
		}
		// We want chronological order and SimplePie uses reverse order.
		for ($i = count($items) - 1; $i >= 0; $i--) {
			$item = $items[$i];
			if ($item == null) {
				continue;
			}
			$title = html_only_entity_decode(strip_tags($item->get_title() ?? ''));
			$authors = $item->get_authors();
			$link = $item->get_permalink();
			$date = @strtotime((string)($item->get_date() ?? '')) ?: 0;

			//Tag processing (tag == category)
			$categories = $item->get_categories();
			$tags = [];
			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 (!empty($labels)) {
						foreach ($labels as $label) {
							$tags[] = trim($label);
						}
					}
				}
				$tags = array_unique($tags);
			}

			$content = html_only_entity_decode($item->get_content());

			$attributeThumbnail = $item->get_thumbnail() ?? [];
			if (empty($attributeThumbnail['url'])) {
				$attributeThumbnail['url'] = '';
			}

			$attributeEnclosures = [];
			if (!empty($item->get_enclosures())) {
				foreach ($item->get_enclosures() as $enclosure) {
					$elink = $enclosure->get_link();
					if ($elink != '') {
						$etitle = $enclosure->get_title() ?? '';
						$credits = $enclosure->get_credits() ?? null;
						$description = $enclosure->get_description() ?? '';
						$mime = strtolower($enclosure->get_type() ?? '');
						$medium = strtolower($enclosure->get_medium() ?? '');
						$height = $enclosure->get_height();
						$width = $enclosure->get_width();
						$length = $enclosure->get_length();

						$attributeEnclosure = [
							'url' => $elink,
						];
						if ($etitle != '') {
							$attributeEnclosure['title'] = $etitle;
						}
						if (is_array($credits)) {
							$attributeEnclosure['credit'] = [];
							foreach ($credits as $credit) {
								$attributeEnclosure['credit'][] = $credit->get_name();
							}
						}
						if ($description != '') {
							$attributeEnclosure['description'] = $description;
						}
						if ($mime != '') {
							$attributeEnclosure['type'] = $mime;
						}
						if ($medium != '') {
							$attributeEnclosure['medium'] = $medium;
						}
						if ($length != '') {
							$attributeEnclosure['length'] = (int)$length;
						}
						if ($height != '') {
							$attributeEnclosure['height'] = (int)$height;
						}
						if ($width != '') {
							$attributeEnclosure['width'] = (int)$width;
						}

						if (!empty($enclosure->get_thumbnails())) {
							foreach ($enclosure->get_thumbnails() as $thumbnail) {
								if ($thumbnail !== $attributeThumbnail['url']) {
									$attributeEnclosure['thumbnails'][] = $thumbnail;
								}
							}
						}

						$attributeEnclosures[] = $attributeEnclosure;
					}
				}
			}

			$guid = safe_ascii($item->get_id(false, false));
			unset($item);

			$authorNames = '';
			if (is_array($authors)) {
				foreach ($authors as $author) {
					$authorName = $author->name != '' ? $author->name : $author->email;
					if ($authorName != '') {
						$authorNames .= escapeToUnicodeAlternative(strip_tags($authorName), true) . '; ';
					}
				}
			}
			$authorNames = substr($authorNames, 0, -2) ?: '';

			$entry = new FreshRSS_Entry(
				$this->id(),
				$hasBadGuids ? '' : $guid,
				$title == '' ? '' : $title,
				$authorNames,
				$content == '' ? '' : $content,
				$link == null ? '' : $link,
				$date ?: time()
			);
			$entry->_tags($tags);
			$entry->_feed($this);
			if (!empty($attributeThumbnail['url'])) {
				$entry->_attribute('thumbnail', $attributeThumbnail);
			}
			$entry->_attribute('enclosures', $attributeEnclosures);
			$entry->hash();	//Must be computed before loading full content
			$entry->loadCompleteContent();	// Optionally load full content for truncated feeds

			yield $entry;
		}
	}

	/**
	 * Given a feed content generated from a FreshRSS_View
	 * returns a SimplePie initialized already with that content
	 * @param string $feedContent the content of the feed, typically generated via FreshRSS_View::renderToString()
	 */
	private function simplePieFromContent(string $feedContent): SimplePie {
		$simplePie = customSimplePie();
		$simplePie->set_raw_data($feedContent);
		$simplePie->init();
		return $simplePie;
	}

	/** @return array<string,string> */
	private function dotNotationForStandardJsonFeed(): array {
		return [
			'feedTitle' => 'title',
			'item' => 'items',
			'itemTitle' => 'title',
			'itemContent' => 'content_text',
			'itemContentHTML' => 'content_html',
			'itemUri' => 'url',
			'itemTimestamp' => 'date_published',
			'itemTimeFormat' => DateTimeInterface::RFC3339_EXTENDED,
			'itemThumbnail' => 'image',
			'itemCategories' => 'tags',
			'itemUid' => 'id',
			'itemAttachment' => 'attachments',
			'itemAttachmentUrl' => 'url',
			'itemAttachmentType' => 'mime_type',
			'itemAttachmentLength' => 'size_in_bytes',
		];
	}

	public function loadJson(): ?SimplePie {
		if ($this->url == '') {
			return null;
		}
		$feedSourceUrl = htmlspecialchars_decode($this->url, ENT_QUOTES);
		if ($feedSourceUrl == null) {
			return null;
		}

		$httpAccept = 'json';
		$json = httpGet($feedSourceUrl, $this->cacheFilename(), $httpAccept, $this->attributes(), $this->curlOptions());
		if (strlen($json) <= 0) {
			return null;
		}

		//check if the content is actual JSON
		$jf = json_decode($json, true);
		if (json_last_error() !== JSON_ERROR_NONE || !is_array($jf)) {
			return null;
		}

		/** @var array<string,string> $json_dotnotation */
		$json_dotnotation = $this->attributeArray('json_dotnotation') ?? [];
		$dotnotations = $this->kind() === FreshRSS_Feed::KIND_JSONFEED ? $this->dotNotationForStandardJsonFeed() : $json_dotnotation;

		$feedContent = FreshRSS_dotNotation_Util::convertJsonToRss($jf, $feedSourceUrl, $dotnotations, $this->name());
		if ($feedContent == null) {
			return null;
		}
		return $this->simplePieFromContent($feedContent);
	}

	public function loadHtmlXpath(): ?SimplePie {
		if ($this->url == '') {
			return null;
		}
		$feedSourceUrl = htmlspecialchars_decode($this->url, ENT_QUOTES);
		if ($feedSourceUrl == null) {
			return null;
		}

		// Same naming conventions than https://rss-bridge.github.io/rss-bridge/Bridge_API/XPathAbstract.html
		// https://rss-bridge.github.io/rss-bridge/Bridge_API/BridgeAbstract.html#collectdata
		/** @var array<string,string> $xPathSettings */
		$xPathSettings = $this->attributeArray('xpath');
		$xPathFeedTitle = $xPathSettings['feedTitle'] ?? '';
		$xPathItem = $xPathSettings['item'] ?? '';
		$xPathItemTitle = $xPathSettings['itemTitle'] ?? '';
		$xPathItemContent = $xPathSettings['itemContent'] ?? '';
		$xPathItemUri = $xPathSettings['itemUri'] ?? '';
		$xPathItemAuthor = $xPathSettings['itemAuthor'] ?? '';
		$xPathItemTimestamp = $xPathSettings['itemTimestamp'] ?? '';
		$xPathItemTimeFormat = $xPathSettings['itemTimeFormat'] ?? '';
		$xPathItemThumbnail = $xPathSettings['itemThumbnail'] ?? '';
		$xPathItemCategories = $xPathSettings['itemCategories'] ?? '';
		$xPathItemUid = $xPathSettings['itemUid'] ?? '';
		if ($xPathItem == '') {
			return null;
		}

		$httpAccept = $this->kind() === FreshRSS_Feed::KIND_XML_XPATH ? 'xml' : 'html';
		$html = httpGet($feedSourceUrl, $this->cacheFilename(), $httpAccept, $this->attributes(), $this->curlOptions());
		if (strlen($html) <= 0) {
			return null;
		}

		$view = new FreshRSS_View();
		$view->_path('index/rss.phtml');
		$view->internal_rendering = true;
		$view->rss_url = htmlspecialchars($feedSourceUrl, ENT_COMPAT, 'UTF-8');
		$view->html_url = $view->rss_url;
		$view->entries = [];

		try {
			$doc = new DOMDocument();
			$doc->recover = true;
			$doc->strictErrorChecking = false;
			$ok = false;

			switch ($this->kind()) {
				case FreshRSS_Feed::KIND_HTML_XPATH:
					$ok = $doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING) !== false;
					break;
				case FreshRSS_Feed::KIND_XML_XPATH:
					$ok = $doc->loadXML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING) !== false;
					break;
			}

			if (!$ok) {
				return null;
			}

			$xpath = new DOMXPath($doc);
			$xpathEvaluateString = function (string $expression, ?DOMNode $contextNode = null) use ($xpath): string {
				$result = @$xpath->evaluate('normalize-space(' . $expression . ')', $contextNode);
				return is_string($result) ? $result : '';
			};

			$view->rss_title = $xPathFeedTitle == '' ? $this->name() :
				htmlspecialchars($xpathEvaluateString($xPathFeedTitle), ENT_COMPAT, 'UTF-8');
			$view->rss_base = htmlspecialchars(trim($xpathEvaluateString('//base/@href')), ENT_COMPAT, 'UTF-8');
			$nodes = $xpath->query($xPathItem);
			if ($nodes === false || $nodes->length === 0) {
				return null;
			}

			foreach ($nodes as $node) {
				$item = [];
				$item['title'] = $xPathItemTitle == '' ? '' : $xpathEvaluateString($xPathItemTitle, $node);

				$item['content'] = '';
				if ($xPathItemContent != '') {
					$result = @$xpath->evaluate($xPathItemContent, $node);
					if ($result instanceof DOMNodeList) {
						// List of nodes, save as HTML
						$content = '';
						foreach ($result as $child) {
							$content .= $doc->saveHTML($child) . "\n";
						}
						$item['content'] = $content;
					} elseif (is_string($result) || is_int($result) || is_bool($result)) {
						// Typed expression, save as-is
						$item['content'] = (string)$result;
					}
				}

				$item['link'] = $xPathItemUri == '' ? '' : $xpathEvaluateString($xPathItemUri, $node);
				$item['author'] = $xPathItemAuthor == '' ? '' : $xpathEvaluateString($xPathItemAuthor, $node);
				$item['timestamp'] = $xPathItemTimestamp == '' ? '' : $xpathEvaluateString($xPathItemTimestamp, $node);
				if ($xPathItemTimeFormat != '') {
					$dateTime = DateTime::createFromFormat($xPathItemTimeFormat, $item['timestamp']);
					if ($dateTime != false) {
						$item['timestamp'] = $dateTime->format(DateTime::ATOM);
					}
				}
				$item['thumbnail'] = $xPathItemThumbnail == '' ? '' : $xpathEvaluateString($xPathItemThumbnail, $node);
				if ($xPathItemCategories != '') {
					$itemCategories = @$xpath->evaluate($xPathItemCategories, $node);
					if (is_string($itemCategories) && $itemCategories !== '') {
						$item['tags'] = [$itemCategories];
					} elseif ($itemCategories instanceof DOMNodeList && $itemCategories->length > 0) {
						$item['tags'] = [];
						foreach ($itemCategories as $itemCategory) {
							$item['tags'][] = $itemCategory->textContent;
						}
					}
				}
				if ($xPathItemUid != '') {
					$item['guid'] = $xpathEvaluateString($xPathItemUid, $node);
				}
				if (empty($item['guid'])) {
					$item['guid'] = 'urn:sha1:' . sha1($item['title'] . $item['content'] . $item['link']);
				}

				if ($item['title'] != '' || $item['content'] != '' || $item['link'] != '') {
					// HTML-encoding/escaping of the relevant fields (all except 'content')
					foreach (['author', 'guid', 'link', 'thumbnail', 'timestamp', 'tags', 'title'] as $key) {
						if (!empty($item[$key]) && is_string($item[$key])) {
							$item[$key] = Minz_Helper::htmlspecialchars_utf8($item[$key]);
						}
					}
					// CDATA protection
					$item['content'] = str_replace(']]>', ']]&gt;', $item['content']);
					$view->entries[] = FreshRSS_Entry::fromArray($item);
				}
			}
		} catch (Exception $ex) {
			Minz_Log::warning($ex->getMessage());
			return null;
		}
		return $this->simplePieFromContent($view->renderToString());
	}

	/**
	 * @return int|null The max number of unread articles to keep, or null if disabled.
	 */
	public function keepMaxUnread() {
		$keepMaxUnread = $this->attributeInt('keep_max_n_unread');
		if ($keepMaxUnread === null) {
			$keepMaxUnread = FreshRSS_Context::userConf()->mark_when['max_n_unread'];
		}
		return is_int($keepMaxUnread) && $keepMaxUnread >= 0 ? $keepMaxUnread : null;
	}

	/**
	 * @return int|false The number of articles marked as read, of false if error
	 */
	public function markAsReadMaxUnread() {
		$keepMaxUnread = $this->keepMaxUnread();
		if ($keepMaxUnread === null) {
			return false;
		}
		$feedDAO = FreshRSS_Factory::createFeedDao();
		$affected = $feedDAO->markAsReadMaxUnread($this->id(), $keepMaxUnread);
		return $affected;
	}

	/**
	 * Applies the *mark as read upon gone* policy, if enabled.
	 * Remember to call `updateCachedValues($id_feed)` or `updateCachedValues()` just after.
	 * @return int|false the number of lines affected, or false if not applicable
	 */
	public function markAsReadUponGone(bool $upstreamIsEmpty, int $minLastSeen = 0) {
		$readUponGone = $this->attributeBoolean('read_upon_gone');
		if ($readUponGone === null) {
			$readUponGone = FreshRSS_Context::userConf()->mark_when['gone'];
		}
		if (!$readUponGone) {
			return false;
		}
		if ($upstreamIsEmpty) {
			if ($minLastSeen <= 0) {
				$minLastSeen = time();
			}
			$entryDAO = FreshRSS_Factory::createEntryDao();
			$affected = $entryDAO->markReadFeed($this->id(), $minLastSeen . '000000');
		} else {
			$feedDAO = FreshRSS_Factory::createFeedDao();
			$affected = $feedDAO->markAsReadNotSeen($this->id(), $minLastSeen);
		}
		if ($affected > 0) {
			Minz_Log::debug(__METHOD__ . " $affected items" . ($upstreamIsEmpty ? ' (all)' : '') . ' [' . $this->url(false) . ']');
		}
		return $affected;
	}

	/**
	 * Remember to call `updateCachedValues($id_feed)` or `updateCachedValues()` just after
	 * @return int|false
	 */
	public function cleanOldEntries() {
		/** @var array<string,bool|int|string>|null $archiving */
		$archiving = $this->attributeArray('archiving');
		if ($archiving === null) {
			$catDAO = FreshRSS_Factory::createCategoryDao();
			$category = $catDAO->searchById($this->categoryId);
			$archiving = $category === null ? null : $category->attributeArray('archiving');
			/** @var array<string,bool|int|string>|null $archiving */
			if ($archiving === null) {
				$archiving = FreshRSS_Context::userConf()->archiving;
			}
		}
		if (is_array($archiving)) {
			$entryDAO = FreshRSS_Factory::createEntryDao();
			$nb = $entryDAO->cleanOldEntries($this->id(), $archiving);
			if ($nb > 0) {
				Minz_Log::debug($nb . ' entries cleaned in feed [' . $this->url(false) . '] with: ' . json_encode($archiving));
			}
			return $nb;
		}
		return false;
	}

	/**
	 * @param string $url Overridden URL. Will default to the feed URL.
	 * @throws FreshRSS_Context_Exception
	 */
	public function cacheFilename(string $url = ''): string {
		$simplePie = customSimplePie($this->attributes(), $this->curlOptions());
		if ($url !== '') {
			$filename = $simplePie->get_cache_filename($url);
			return CACHE_PATH . '/' . $filename . '.html';
		}
		$url = htmlspecialchars_decode($this->url);
		$filename = $simplePie->get_cache_filename($url);
		if ($this->kind === FreshRSS_Feed::KIND_HTML_XPATH) {
			return CACHE_PATH . '/' . $filename . '.html';
		} elseif ($this->kind === FreshRSS_Feed::KIND_XML_XPATH) {
			return CACHE_PATH . '/' . $filename . '.xml';
		} else {
			return CACHE_PATH . '/' . $filename . '.spc';
		}
	}

	public function clearCache(): bool {
		return @unlink($this->cacheFilename());
	}

	/** @return int|false */
	public function cacheModifiedTime() {
		$filename = $this->cacheFilename();
		clearstatcache(true, $filename);
		return @filemtime($filename);
	}

	public function lock(): bool {
		$this->lockPath = TMP_PATH . '/' . $this->hash() . '.freshrss.lock';
		if (file_exists($this->lockPath) && ((time() - (@filemtime($this->lockPath) ?: 0)) > 3600)) {
			@unlink($this->lockPath);
		}
		if (($handle = @fopen($this->lockPath, 'x')) === false) {
			return false;
		}
		//register_shutdown_function('unlink', $this->lockPath);
		@fclose($handle);
		return true;
	}

	public function unlock(): bool {
		return @unlink($this->lockPath);
	}

	//<WebSub>

	public function pubSubHubbubEnabled(): bool {
		$url = $this->selfUrl ?: $this->url;
		$hubFilename = PSHB_PATH . '/feeds/' . sha1($url) . '/!hub.json';
		if ($hubFile = @file_get_contents($hubFilename)) {
			$hubJson = json_decode($hubFile, true);
			if (is_array($hubJson) && empty($hubJson['error']) &&
				(empty($hubJson['lease_end']) || $hubJson['lease_end'] > time())) {
				return true;
			}
		}
		return false;
	}

	public function pubSubHubbubError(bool $error = true): bool {
		$url = $this->selfUrl ?: $this->url;
		$hubFilename = PSHB_PATH . '/feeds/' . sha1($url) . '/!hub.json';
		$hubFile = @file_get_contents($hubFilename);
		$hubJson = is_string($hubFile) ? json_decode($hubFile, true) : null;
		if (is_array($hubJson) && (!isset($hubJson['error']) || $hubJson['error'] !== $error)) {
			$hubJson['error'] = $error;
			file_put_contents($hubFilename, json_encode($hubJson));
			Minz_Log::warning('Set error to ' . ($error ? 1 : 0) . ' for ' . $url, PSHB_LOG);
		}
		return false;
	}

	/**
	 * @return string|false
	 */
	public function pubSubHubbubPrepare() {
		$key = '';
		if (Minz_Request::serverIsPublic(FreshRSS_Context::systemConf()->base_url) &&
			$this->hubUrl && $this->selfUrl && @is_dir(PSHB_PATH)) {
			$path = PSHB_PATH . '/feeds/' . sha1($this->selfUrl);
			$hubFilename = $path . '/!hub.json';
			if ($hubFile = @file_get_contents($hubFilename)) {
				$hubJson = json_decode($hubFile, true);
				if (!is_array($hubJson) || empty($hubJson['key']) || !ctype_xdigit($hubJson['key'])) {
					$text = 'Invalid JSON for WebSub: ' . $this->url;
					Minz_Log::warning($text);
					Minz_Log::warning($text, PSHB_LOG);
					return false;
				}
				if ((!empty($hubJson['lease_end'])) && ($hubJson['lease_end'] < (time() + (3600 * 23)))) {	//TODO: Make a better policy
					$text = 'WebSub lease ends at '
						. date('c', empty($hubJson['lease_end']) ? time() : $hubJson['lease_end'])
						. ' and needs renewal: ' . $this->url;
					Minz_Log::warning($text);
					Minz_Log::warning($text, PSHB_LOG);
					$key = $hubJson['key'];	//To renew our lease
				} elseif (((!empty($hubJson['error'])) || empty($hubJson['lease_end'])) &&
					(empty($hubJson['lease_start']) || $hubJson['lease_start'] < time() - (3600 * 23))) {	//Do not renew too often
					$key = $hubJson['key'];	//To renew our lease
				}
			} else {
				@mkdir($path, 0770, true);
				$key = sha1($path . FreshRSS_Context::systemConf()->salt);
				$hubJson = [
					'hub' => $this->hubUrl,
					'key' => $key,
				];
				file_put_contents($hubFilename, json_encode($hubJson));
				@mkdir(PSHB_PATH . '/keys/', 0770, true);
				file_put_contents(PSHB_PATH . '/keys/' . $key . '.txt', $this->selfUrl);
				$text = 'WebSub prepared for ' . $this->url;
				Minz_Log::debug($text);
				Minz_Log::debug($text, PSHB_LOG);
			}
			$currentUser = Minz_User::name() ?? '';
			if (FreshRSS_user_Controller::checkUsername($currentUser) && !file_exists($path . '/' . $currentUser . '.txt')) {
				touch($path . '/' . $currentUser . '.txt');
			}
		}
		return $key;
	}

	//Parameter true to subscribe, false to unsubscribe.
	public function pubSubHubbubSubscribe(bool $state): bool {
		if ($state) {
			$url = $this->selfUrl ?: $this->url;
		} else {
			$url = $this->url;	//Always use current URL during unsubscribe
		}
		if ($url && (Minz_Request::serverIsPublic(FreshRSS_Context::systemConf()->base_url) || !$state)) {
			$hubFilename = PSHB_PATH . '/feeds/' . sha1($url) . '/!hub.json';
			$hubFile = @file_get_contents($hubFilename);
			if ($hubFile === false) {
				Minz_Log::warning('JSON not found for WebSub: ' . $this->url);
				return false;
			}
			$hubJson = json_decode($hubFile, true);
			if (!is_array($hubJson) || empty($hubJson['key']) || !ctype_xdigit($hubJson['key']) || empty($hubJson['hub'])) {
				Minz_Log::warning('Invalid JSON for WebSub: ' . $this->url);
				return false;
			}
			$callbackUrl = checkUrl(Minz_Request::getBaseUrl() . '/api/pshb.php?k=' . $hubJson['key']);
			if ($callbackUrl == '') {
				Minz_Log::warning('Invalid callback for WebSub: ' . $this->url);
				return false;
			}
			if (!$state) {	//unsubscribe
				$hubJson['lease_end'] = time() - 60;
				file_put_contents($hubFilename, json_encode($hubJson));
			}
			$ch = curl_init();
			curl_setopt_array($ch, [
				CURLOPT_URL => $hubJson['hub'],
				CURLOPT_RETURNTRANSFER => true,
				CURLOPT_POSTFIELDS => http_build_query([
					'hub.verify' => 'sync',
					'hub.mode' => $state ? 'subscribe' : 'unsubscribe',
					'hub.topic' => $url,
					'hub.callback' => $callbackUrl,
				]),
				CURLOPT_USERAGENT => FRESHRSS_USERAGENT,
				CURLOPT_MAXREDIRS => 10,
				CURLOPT_FOLLOWLOCATION => true,
				CURLOPT_ENCODING => '',	//Enable all encodings
				//CURLOPT_VERBOSE => 1,	// To debug sent HTTP headers
			]);
			$response = curl_exec($ch);
			$info = curl_getinfo($ch);

			Minz_Log::warning('WebSub ' . ($state ? 'subscribe' : 'unsubscribe') . ' to ' . $url .
				' via hub ' . $hubJson['hub'] .
				' with callback ' . $callbackUrl . ': ' . $info['http_code'] . ' ' . $response, PSHB_LOG);

			if (substr('' . $info['http_code'], 0, 1) == '2') {
				return true;
			} else {
				$hubJson['lease_start'] = time();	//Prevent trying again too soon
				$hubJson['error'] = true;
				file_put_contents($hubFilename, json_encode($hubJson));
				return false;
			}
		}
		return false;
	}

	//</WebSub>
}
FeedDAO.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/FeedDAO.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_FeedDAO extends Minz_ModelPdo {

	protected function addColumn(string $name): bool {
		if ($this->pdo->inTransaction()) {
			$this->pdo->commit();
		}
		Minz_Log::warning(__method__ . ': ' . $name);
		try {
			if ($name === 'kind') {	//v1.20.0
				return $this->pdo->exec('ALTER TABLE `_feed` ADD COLUMN kind SMALLINT DEFAULT 0') !== false;
			}
		} catch (Exception $e) {
			Minz_Log::error(__method__ . ' error: ' . $e->getMessage());
		}
		return false;
	}

	/** @param array<int|string> $errorInfo */
	protected function autoUpdateDb(array $errorInfo): bool {
		if (isset($errorInfo[0])) {
			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
				$errorLines = explode("\n", (string)$errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
				foreach (['kind'] as $column) {
					if (stripos($errorLines[0], $column) !== false) {
						return $this->addColumn($column);
					}
				}
			}
		}
		return false;
	}

	/**
	 * @param array{'url':string,'kind':int,'category':int,'name':string,'website':string,'description':string,'lastUpdate':int,'priority'?:int,
	 * 	'pathEntries'?:string,'httpAuth':string,'error':int|bool,'ttl'?:int,'attributes'?:string|array<string|mixed>} $valuesTmp
	 * @return int|false
	 */
	public function addFeed(array $valuesTmp) {
		$sql = 'INSERT INTO `_feed` (url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
				VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
		$stm = $this->pdo->prepare($sql);

		$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
		$valuesTmp['website'] = safe_ascii($valuesTmp['website']);
		if (!isset($valuesTmp['pathEntries'])) {
			$valuesTmp['pathEntries'] = '';
		}
		if (!isset($valuesTmp['attributes'])) {
			$valuesTmp['attributes'] = [];
		}

		$values = [
			$valuesTmp['url'],
			$valuesTmp['kind'] ?? FreshRSS_Feed::KIND_RSS,
			$valuesTmp['category'],
			mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'),
			$valuesTmp['website'],
			sanitizeHTML($valuesTmp['description'], ''),
			$valuesTmp['lastUpdate'],
			isset($valuesTmp['priority']) ? (int)$valuesTmp['priority'] : FreshRSS_Feed::PRIORITY_MAIN_STREAM,
			mb_strcut($valuesTmp['pathEntries'], 0, 4096, 'UTF-8'),
			base64_encode($valuesTmp['httpAuth']),
			isset($valuesTmp['error']) ? (int)$valuesTmp['error'] : 0,
			isset($valuesTmp['ttl']) ? (int)$valuesTmp['ttl'] : FreshRSS_Feed::TTL_DEFAULT,
			is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
		];

		if ($stm !== false && $stm->execute($values)) {
			$feedId = $this->pdo->lastInsertId('`_feed_id_seq`');
			return $feedId === false ? false : (int)$feedId;
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			if ($this->autoUpdateDb($info)) {
				return $this->addFeed($valuesTmp);
			}
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/** @return int|false */
	public function addFeedObject(FreshRSS_Feed $feed) {
		// Add feed only if we don’t find it in DB
		$feed_search = $this->searchByUrl($feed->url());
		if (!$feed_search) {
			$values = [
				'id' => $feed->id(),
				'url' => $feed->url(),
				'kind' => $feed->kind(),
				'category' => $feed->categoryId(),
				'name' => $feed->name(true),
				'website' => $feed->website(),
				'description' => $feed->description(),
				'lastUpdate' => 0,
				'error' => false,
				'pathEntries' => $feed->pathEntries(),
				'httpAuth' => $feed->httpAuth(),
				'ttl' => $feed->ttl(true),
				'attributes' => $feed->attributes(),
			];

			$id = $this->addFeed($values);
			if ($id) {
				$feed->_id($id);
				$feed->faviconPrepare();
			}

			return $id;
		} else {
			// The feed already exists so make sure it is not muted
			$feed->_ttl($feed_search->ttl());
			$feed->_mute(false);

			// Merge existing and import attributes
			$existingAttributes = $feed_search->attributes();
			$importAttributes = $feed->attributes();
			$feed->_attributes(array_replace_recursive($existingAttributes, $importAttributes));

			// Update some values of the existing feed using the import
			$values = [
				'kind' => $feed->kind(),
				'name' => $feed->name(true),
				'website' => $feed->website(),
				'description' => $feed->description(),
				'pathEntries' => $feed->pathEntries(),
				'ttl' => $feed->ttl(true),
				'attributes' => $feed->attributes(),
			];

			if (!$this->updateFeed($feed_search->id(), $values)) {
				return false;
			}

			return $feed_search->id();
		}
	}

	/**
	 * @param array{'url'?:string,'kind'?:int,'category'?:int,'name'?:string,'website'?:string,'description'?:string,'lastUpdate'?:int,'priority'?:int,
	 * 	'pathEntries'?:string,'httpAuth'?:string,'error'?:int,'ttl'?:int,'attributes'?:string|array<string,mixed>} $valuesTmp $valuesTmp
	 * @return int|false
	 */
	public function updateFeed(int $id, array $valuesTmp) {
		$values = [];
		$originalValues = $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']);
		}
		if (isset($valuesTmp['website'])) {
			$valuesTmp['website'] = safe_ascii($valuesTmp['website']);
		}

		$set = '';
		foreach ($valuesTmp as $key => $v) {
			$set .= '`' . $key . '`=?, ';

			if ($key === 'httpAuth') {
				$valuesTmp[$key] = base64_encode($v);
			} elseif ($key === 'attributes') {
				$valuesTmp[$key] = is_string($valuesTmp[$key]) ? $valuesTmp[$key] : json_encode($valuesTmp[$key], JSON_UNESCAPED_SLASHES);
			}
		}
		$set = substr($set, 0, -2);

		$sql = 'UPDATE `_feed` SET ' . $set . ' WHERE id=?';
		$stm = $this->pdo->prepare($sql);

		foreach ($valuesTmp as $v) {
			$values[] = $v;
		}
		$values[] = $id;

		if ($stm !== false && $stm->execute($values)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			if ($this->autoUpdateDb($info)) {
				return $this->updateFeed($id, $originalValues);
			}
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info) . ' for feed ' . $id);
			return false;
		}
	}

	/**
	 * @param non-empty-string $key
	 * @param string|array<mixed>|bool|int|null $value
	 * @return int|false
	 */
	public function updateFeedAttribute(FreshRSS_Feed $feed, string $key, $value) {
		$feed->_attribute($key, $value);
		return $this->updateFeed(
			$feed->id(),
			['attributes' => $feed->attributes()]
		);
	}

	/**
	 * @return int|false
	 * @see updateCachedValues()
	 */
	public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0) {
		$sql = 'UPDATE `_feed` SET `lastUpdate`=?, error=? WHERE id=?';
		$values = [
			$mtime <= 0 ? time() : $mtime,
			$inError ? 1 : 0,
			$id,
		];
		$stm = $this->pdo->prepare($sql);

		if ($stm !== false && $stm->execute($values)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
			return false;
		}
	}

	/** @return int|false */
	public function mute(int $id, bool $value = true) {
		$sql = 'UPDATE `_feed` SET ttl=' . ($value ? '-' : '') . 'ABS(ttl) WHERE id=' . intval($id);
		return $this->pdo->exec($sql);
	}

	/** @return int|false */
	public function changeCategory(int $idOldCat, int $idNewCat) {
		$catDAO = FreshRSS_Factory::createCategoryDao();
		$newCat = $catDAO->searchById($idNewCat);
		if ($newCat === null) {
			$newCat = $catDAO->getDefault();
		}
		if ($newCat === null) {
			return false;
		}

		$sql = 'UPDATE `_feed` SET category=? WHERE category=?';
		$stm = $this->pdo->prepare($sql);

		$values = [
			$newCat->id(),
			$idOldCat,
		];

		if ($stm !== false && $stm->execute($values)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/** @return int|false */
	public function deleteFeed(int $id) {
		$sql = 'DELETE FROM `_feed` WHERE id=?';
		$stm = $this->pdo->prepare($sql);

		$values = [$id];

		if ($stm !== false && $stm->execute($values)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/**
	 * @param bool|null $muted to include only muted feeds
	 * @return int|false
	 */
	public function deleteFeedByCategory(int $id, ?bool $muted = null) {
		$sql = 'DELETE FROM `_feed` WHERE category=?';
		if ($muted) {
			$sql .= ' AND ttl < 0';
		}
		$stm = $this->pdo->prepare($sql);

		$values = [$id];

		if ($stm !== false && $stm->execute($values)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/** @return Traversable<array{'id':int,'url':string,'kind':int,'category':int,'name':string,'website':string,'description':string,'lastUpdate':int,'priority'?:int,
	 * 	'pathEntries'?:string,'httpAuth':string,'error':int|bool,'ttl'?:int,'attributes'?:string}> */
	public function selectAll(): Traversable {
		$sql = <<<'SQL'
SELECT id, url, kind, category, name, website, description, `lastUpdate`,
	priority, `pathEntries`, `httpAuth`, error, ttl, attributes
FROM `_feed`
SQL;
		$stm = $this->pdo->query($sql);
		if ($stm !== false) {
			while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
				/** @var array{'id':int,'url':string,'kind':int,'category':int,'name':string,'website':string,'description':string,'lastUpdate':int,'priority'?:int,
				 *	'pathEntries'?:string,'httpAuth':string,'error':int|bool,'ttl'?:int,'attributes'?:string} $row */
				yield $row;
			}
		} else {
			$info = $this->pdo->errorInfo();
			if ($this->autoUpdateDb($info)) {
				yield from $this->selectAll();
			} else {
				Minz_Log::error(__method__ . ' error: ' . json_encode($info));
			}
		}
	}

	public function searchById(int $id): ?FreshRSS_Feed {
		$sql = 'SELECT * FROM `_feed` WHERE id=:id';
		$res = $this->fetchAssoc($sql, [':id' => $id]);
		if ($res == null) {
			return null;
		}
		/** @var array<int,array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
		 *	'priority'?:int,'pathEntries'?:string,'httpAuth':string,'error':int,'ttl'?:int,'attributes'?:string}> $res */
		$feeds = self::daoToFeeds($res);
		return $feeds[$id] ?? null;
	}

	public function searchByUrl(string $url): ?FreshRSS_Feed {
		$sql = 'SELECT * FROM `_feed` WHERE url=:url';
		$res = $this->fetchAssoc($sql, [':url' => $url]);
		/** @var array<int,array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
		 *	'priority'?:int,'pathEntries'?:string,'httpAuth':string,'error':int,'ttl'?:int,'attributes'?:string}> $res */
		return empty($res[0]) ? null : (current(self::daoToFeeds($res)) ?: null);
	}

	/** @return array<int> */
	public function listFeedsIds(): array {
		$sql = 'SELECT id FROM `_feed`';
		/** @var array<int> $res */
		$res = $this->fetchColumn($sql, 0) ?? [];
		return $res;
	}

	/**
	 * @return array<int,FreshRSS_Feed>
	 */
	public function listFeeds(): array {
		$sql = 'SELECT * FROM `_feed` ORDER BY name';
		$res = $this->fetchAssoc($sql);
		/** @var array<array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
		 *	'priority':int,'pathEntries':string,'httpAuth':string,'error':int,'ttl':int,'attributes':string}>|null $res */
		return $res == null ? [] : self::daoToFeeds($res);
	}

	/** @return array<string,string> */
	public function listFeedsNewestItemUsec(?int $id_feed = null): array {
		$sql = 'SELECT id_feed, MAX(id) as newest_item_us FROM `_entry` ';
		if ($id_feed === null) {
			$sql .= 'GROUP BY id_feed';
		} else {
			$sql .= 'WHERE id_feed=' . intval($id_feed);
		}
		$res = $this->fetchAssoc($sql);
		/** @var array<array{'id_feed':int,'newest_item_us':string}>|null $res */
		if ($res == null) {
			return [];
		}
		$newestItemUsec = [];
		foreach ($res as $line) {
			$newestItemUsec['f_' . $line['id_feed']] = $line['newest_item_us'];
		}
		return $newestItemUsec;
	}

	/**
	 * @param int $defaultCacheDuration Use -1 to return all feeds, without filtering them by TTL.
	 * @return array<int,FreshRSS_Feed>
	 */
	public function listFeedsOrderUpdate(int $defaultCacheDuration = 3600, int $limit = 0): array {
		$sql = 'SELECT id, url, kind, category, name, website, `lastUpdate`, `pathEntries`, `httpAuth`, ttl, attributes, `cache_nbEntries`, `cache_nbUnreads` '
			. 'FROM `_feed` '
			. ($defaultCacheDuration < 0 ? '' : 'WHERE ttl >= ' . FreshRSS_Feed::TTL_DEFAULT
				. ' AND `lastUpdate` < (' . (time() + 60)
				. '-(CASE WHEN ttl=' . FreshRSS_Feed::TTL_DEFAULT . ' THEN ' . intval($defaultCacheDuration) . ' ELSE ttl END)) ')
			. 'ORDER BY `lastUpdate` '
			. ($limit < 1 ? '' : 'LIMIT ' . intval($limit));
		$stm = $this->pdo->query($sql);
		if ($stm !== false) {
			return self::daoToFeeds($stm->fetchAll(PDO::FETCH_ASSOC));
		} else {
			$info = $this->pdo->errorInfo();
			if ($this->autoUpdateDb($info)) {
				return $this->listFeedsOrderUpdate($defaultCacheDuration, $limit);
			}
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return [];
		}
	}

	/** @return array<int,string> */
	public function listTitles(int $id, int $limit = 0): array {
		$sql = 'SELECT title FROM `_entry` WHERE id_feed=:id_feed ORDER BY id DESC'
			. ($limit < 1 ? '' : ' LIMIT ' . intval($limit));
		$res = $this->fetchColumn($sql, 0, [':id_feed' => $id]) ?? [];
		/** @var array<int,string> $res */
		return $res;
	}

	/**
	 * @param bool|null $muted to include only muted feeds
	 * @return array<int,FreshRSS_Feed>
	 */
	public function listByCategory(int $cat, ?bool $muted = null): array {
		$sql = 'SELECT * FROM `_feed` WHERE category=:category';
		if ($muted) {
			$sql .= ' AND ttl < 0';
		}
		$res = $this->fetchAssoc($sql, [':category' => $cat]);
		if ($res == null) {
			return [];
		}

		/**
		 * @var array<int,array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
		 *	'priority'?:int,'pathEntries'?:string,'httpAuth':string,'error':int,'ttl'?:int,'attributes'?:string}> $res
		 */
		$feeds = self::daoToFeeds($res);

		uasort($feeds, static function (FreshRSS_Feed $a, FreshRSS_Feed $b) {
			return strnatcasecmp($a->name(), $b->name());
		});

		return $feeds;
	}

	public function countEntries(int $id): int {
		$sql = 'SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed';
		$res = $this->fetchColumn($sql, 0, ['id_feed' => $id]);
		return isset($res[0]) ? (int)($res[0]) : -1;
	}

	public function countNotRead(int $id): int {
		$sql = 'SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed AND is_read=0';
		$res = $this->fetchColumn($sql, 0, ['id_feed' => $id]);
		return isset($res[0]) ? (int)($res[0]) : -1;
	}

	/**
	 * Update cached values for selected feeds, or all feeds if no feed ID is provided.
	 * @return int|false
	 */
	public function updateCachedValues(int ...$feedIds) {
		//2 sub-requests with FOREIGN KEY(e.id_feed), INDEX(e.is_read) faster than 1 request with GROUP BY or CASE
		$sql = <<<SQL
UPDATE `_feed`
SET `cache_nbEntries`=(SELECT COUNT(e1.id) FROM `_entry` e1 WHERE e1.id_feed=`_feed`.id),
	`cache_nbUnreads`=(SELECT COUNT(e2.id) FROM `_entry` e2 WHERE e2.id_feed=`_feed`.id AND e2.is_read=0)
SQL;
		if (count($feedIds) > 0) {
			$sql .= ' WHERE id IN (' . str_repeat('?,', count($feedIds) - 1) . '?)';
		}
		$stm = $this->pdo->prepare($sql);
		if ($stm !== false && $stm->execute($feedIds)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/**
	 * Remember to call updateCachedValues() after calling this function
	 * @return int|false number of lines affected or false in case of error
	 */
	public function markAsReadMaxUnread(int $id, int $n) {
		//Double SELECT for MySQL workaround ERROR 1093 (HY000)
		$sql = <<<'SQL'
UPDATE `_entry` SET is_read=1
WHERE id_feed=:id_feed1 AND is_read=0 AND id <= (SELECT e3.id FROM (
	SELECT e2.id FROM `_entry` e2
	WHERE e2.id_feed=:id_feed2 AND e2.is_read=0
	ORDER BY e2.id DESC
	LIMIT 1
	OFFSET :limit) e3)
SQL;

		if (($stm = $this->pdo->prepare($sql)) &&
			$stm->bindParam(':id_feed1', $id, PDO::PARAM_INT) &&
			$stm->bindParam(':id_feed2', $id, PDO::PARAM_INT) &&
			$stm->bindParam(':limit', $n, PDO::PARAM_INT) &&
			$stm->execute()) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/**
	 * Remember to call updateCachedValues() after calling this function
	 * @return int|false number of lines affected or false in case of error
	 */
	public function markAsReadNotSeen(int $id, int $minLastSeen) {
		$sql = <<<'SQL'
UPDATE `_entry` SET is_read=1
WHERE id_feed=:id_feed AND is_read=0 AND (`lastSeen` + 10 < :min_last_seen)
SQL;

		if (($stm = $this->pdo->prepare($sql)) &&
			$stm->bindValue(':id_feed', $id, PDO::PARAM_INT) &&
			$stm->bindValue(':min_last_seen', $minLastSeen, PDO::PARAM_INT) &&
			$stm->execute()) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/**
	 * @return int|false
	 */
	public function truncate(int $id) {
		$sql = 'DELETE FROM `_entry` WHERE id_feed=:id';
		$stm = $this->pdo->prepare($sql);
		$this->pdo->beginTransaction();
		if (!($stm !== false &&
			$stm->bindParam(':id', $id, PDO::PARAM_INT) &&
			$stm->execute())) {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			$this->pdo->rollBack();
			return false;
		}
		$affected = $stm->rowCount();

		$sql = 'UPDATE `_feed` SET `cache_nbEntries`=0, `cache_nbUnreads`=0, `lastUpdate`=0 WHERE id=:id';
		$stm = $this->pdo->prepare($sql);
		if (!($stm !== false &&
			$stm->bindParam(':id', $id, PDO::PARAM_INT) &&
			$stm->execute())) {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			$this->pdo->rollBack();
			return false;
		}

		$this->pdo->commit();
		return $affected;
	}

	public function purge(): bool {
		$sql = 'DELETE FROM `_entry`';
		$stm = $this->pdo->prepare($sql);
		$this->pdo->beginTransaction();
		if (!($stm && $stm->execute())) {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
			$this->pdo->rollBack();
			return false;
		}

		$sql = 'UPDATE `_feed` SET `cache_nbEntries` = 0, `cache_nbUnreads` = 0';
		$stm = $this->pdo->prepare($sql);
		if (!($stm && $stm->execute())) {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
			$this->pdo->rollBack();
			return false;
		}

		return $this->pdo->commit();
	}

	/**
	 * @param array<int,array{'id'?:int,'url'?:string,'kind'?:int,'category'?:int,'name'?:string,'website'?:string,'description'?:string,'lastUpdate'?:int,'priority'?:int,
	 * 	'pathEntries'?:string,'httpAuth'?:string,'error'?:int|bool,'ttl'?:int,'attributes'?:string,'cache_nbUnreads'?:int,'cache_nbEntries'?:int}> $listDAO
	 * @return array<int,FreshRSS_Feed>
	 */
	public static function daoToFeeds(array $listDAO, ?int $catID = null): array {
		$list = [];

		foreach ($listDAO as $key => $dao) {
			FreshRSS_DatabaseDAO::pdoInt($dao, ['id', 'kind', 'category', 'lastUpdate', 'priority', 'error', 'ttl', 'cache_nbUnreads', 'cache_nbEntries']);
			if (!isset($dao['name'])) {
				continue;
			}
			if (isset($dao['id'])) {
				$key = (int)$dao['id'];
			}
			if ($catID === null) {
				$category = $dao['category'] ?? 0;
			} else {
				$category = $catID;
			}

			$myFeed = new FreshRSS_Feed($dao['url'] ?? '', false);
			$myFeed->_kind($dao['kind'] ?? FreshRSS_Feed::KIND_RSS);
			$myFeed->_categoryId($category);
			$myFeed->_name($dao['name']);
			$myFeed->_website($dao['website'] ?? '', false);
			$myFeed->_description($dao['description'] ?? '');
			$myFeed->_lastUpdate($dao['lastUpdate'] ?? 0);
			$myFeed->_priority($dao['priority'] ?? 10);
			$myFeed->_pathEntries($dao['pathEntries'] ?? '');
			$myFeed->_httpAuth(base64_decode($dao['httpAuth'] ?? '', true) ?: '');
			$myFeed->_error($dao['error'] ?? 0);
			$myFeed->_ttl($dao['ttl'] ?? FreshRSS_Feed::TTL_DEFAULT);
			$myFeed->_attributes($dao['attributes'] ?? '');
			$myFeed->_nbNotRead($dao['cache_nbUnreads'] ?? -1);
			$myFeed->_nbEntries($dao['cache_nbEntries'] ?? -1);
			if (isset($dao['id'])) {
				$myFeed->_id($dao['id']);
			}
			$list[$key] = $myFeed;
		}

		return $list;
	}

	public function count(): int {
		$sql = 'SELECT COUNT(e.id) AS count FROM `_feed` e';
		$stm = $this->pdo->query($sql);
		if ($stm == false) {
			return -1;
		}
		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
		return (int)($res[0] ?? 0);
	}
}
FeedDAOSQLite.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/FeedDAOSQLite.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_FeedDAOSQLite extends FreshRSS_FeedDAO {

	/** @param array<int|string> $errorInfo */
	#[\Override]
	protected function autoUpdateDb(array $errorInfo): bool {
		if ($tableInfo = $this->pdo->query("PRAGMA table_info('feed')")) {
			$columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1);
			foreach (['attributes', 'kind'] as $column) {
				if (!in_array($column, $columns, true)) {
					return $this->addColumn($column);
				}
			}
		}
		return false;
	}
}
FilterAction.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/FilterAction.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_FilterAction {

	private FreshRSS_BooleanSearch $booleanSearch;
	/** @var array<string>|null */
	private ?array $actions = null;

	/** @param array<string> $actions */
	private function __construct(FreshRSS_BooleanSearch $booleanSearch, array $actions) {
		$this->booleanSearch = $booleanSearch;
		$this->_actions($actions);
	}

	public function booleanSearch(): FreshRSS_BooleanSearch {
		return $this->booleanSearch;
	}

	/** @return array<string> */
	public function actions(): array {
		return $this->actions ?? [];
	}

	/** @param array<string> $actions */
	public function _actions(?array $actions): void {
		if (is_array($actions)) {
			$this->actions = array_unique($actions);
		} else {
			$this->actions = null;
		}
	}

	/** @return array{'search'?:string,'actions'?:array<string>} */
	public function toJSON(): array {
		if (is_array($this->actions) && $this->booleanSearch != null) {
			return [
				'search' => $this->booleanSearch->getRawInput(),
				'actions' => $this->actions,
			];
		}
		return [];
	}

	/** @param array|mixed|null $json */
	public static function fromJSON($json): ?FreshRSS_FilterAction {
		if (is_array($json) && !empty($json['search']) && !empty($json['actions']) && is_array($json['actions'])) {
			return new FreshRSS_FilterAction(new FreshRSS_BooleanSearch($json['search']), $json['actions']);
		}
		return null;
	}
}
FilterActionsTrait.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/FilterActionsTrait.php'
View Content
<?php
declare(strict_types=1);

/**
 * Logic to apply filter actions (for feeds, categories, user configuration...).
 */
trait FreshRSS_FilterActionsTrait {

	/** @var array<FreshRSS_FilterAction>|null $filterActions */
	private ?array $filterActions = null;

	/**
	 * @return array<FreshRSS_FilterAction>
	 */
	private function filterActions(): array {
		if (empty($this->filterActions)) {
			$this->filterActions = [];
			$filters = $this->attributeArray('filters') ?? [];
			foreach ($filters as $filter) {
				$filterAction = FreshRSS_FilterAction::fromJSON($filter);
				if ($filterAction != null) {
					$this->filterActions[] = $filterAction;
				}
			}
		}
		return $this->filterActions;
	}

	/**
	 * @param array<FreshRSS_FilterAction>|null $filterActions
	 */
	private function _filterActions(?array $filterActions): void {
		$this->filterActions = $filterActions;
		if ($this->filterActions !== null && !empty($this->filterActions)) {
			$this->_attribute('filters', array_map(
				static fn(?FreshRSS_FilterAction $af) => $af == null ? null : $af->toJSON(),
				$this->filterActions));
		} else {
			$this->_attribute('filters', null);
		}
	}

	/** @return array<FreshRSS_BooleanSearch> */
	public function filtersAction(string $action): array {
		$action = trim($action);
		if ($action == '') {
			return [];
		}
		$filters = [];
		$filterActions = $this->filterActions();
		for ($i = count($filterActions) - 1; $i >= 0; $i--) {
			$filterAction = $filterActions[$i];
			if (in_array($action, $filterAction->actions(), true)) {
				$filters[] = $filterAction->booleanSearch();
			}
		}
		return $filters;
	}

	/**
	 * @param array<string> $filters
	 */
	public function _filtersAction(string $action, array $filters): void {
		$action = trim($action);
		if ($action === '') {
			return;
		}
		$filters = array_unique(array_map('trim', $filters), SORT_STRING);
		$filterActions = $this->filterActions();

		//Check existing filters
		for ($i = count($filterActions) - 1; $i >= 0; $i--) {
			$filterAction = $filterActions[$i];
			if ($filterAction == null || !is_array($filterAction->actions()) ||
				$filterAction->booleanSearch() == null || trim($filterAction->booleanSearch()->getRawInput()) == '') {
				array_splice($filterActions, $i, 1);
				continue;
			}
			$actions = $filterAction->actions();
			//Remove existing rules with same action
			for ($j = count($actions) - 1; $j >= 0; $j--) {
				if ($actions[$j] === $action) {
					array_splice($actions, $j, 1);
				}
			}
			//Update existing filter with new action
			for ($k = count($filters) - 1; $k >= 0; $k--) {
				$filter = $filters[$k];
				if ($filter === $filterAction->booleanSearch()->getRawInput()) {
					$actions[] = $action;
					array_splice($filters, $k, 1);
				}
			}
			//Save result
			if (empty($actions)) {
				array_splice($filterActions, $i, 1);
			} else {
				$filterAction->_actions($actions);
			}
		}

		//Add new filters
		for ($k = count($filters) - 1; $k >= 0; $k--) {
			$filter = $filters[$k];
			if ($filter != '') {
				$filterAction = FreshRSS_FilterAction::fromJSON([
					'search' => $filter,
					'actions' => [$action],
				]);
				if ($filterAction != null) {
					$filterActions[] = $filterAction;
				}
			}
		}

		if (empty($filterActions)) {
			$filterActions = null;
		}
		$this->_filterActions($filterActions);
	}

	/**
	 * @param bool $applyLabel Parameter by reference, which will be set to true if the callers needs to apply a label to the article entry.
	 */
	public function applyFilterActions(FreshRSS_Entry $entry, ?bool &$applyLabel = null): void {
		$applyLabel = false;
		foreach ($this->filterActions() as $filterAction) {
			if ($entry->matches($filterAction->booleanSearch())) {
				foreach ($filterAction->actions() as $action) {
					switch ($action) {
						case 'read':
							if (!$entry->isRead()) {
								$entry->_isRead(true);
								Minz_ExtensionManager::callHook('entry_auto_read', $entry, 'filter');
							}
							break;
						case 'star':
							if (!$entry->isUpdated()) {
								// Do not apply to updated articles, to avoid overruling a user manual action
								$entry->_isFavorite(true);
							}
							break;
						case 'label':
							if (!$entry->isUpdated()) {
								// Do not apply to updated articles, to avoid overruling a user manual action
								$applyLabel = true;
							}
							break;
					}
				}
			}
		}
	}
}
FormAuth.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/FormAuth.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_FormAuth {
	public static function checkCredentials(string $username, string $hash, string $nonce, string $challenge): bool {
		if (!FreshRSS_user_Controller::checkUsername($username) ||
				!ctype_graph($hash) ||
				!ctype_graph($challenge) ||
				!ctype_alnum($nonce)) {
			Minz_Log::debug("Invalid credential parameters: user={$username}, challenge={$challenge}, nonce={$nonce}");
			return false;
		}

		return password_verify($nonce . $hash, $challenge);
	}

	/** @return array<string> */
	public static function getCredentialsFromCookie(): array {
		$token = Minz_Session::getLongTermCookie('FreshRSS_login');
		if (!ctype_alnum($token)) {
			return [];
		}

		$token_file = DATA_PATH . '/tokens/' . $token . '.txt';
		$mtime = @filemtime($token_file) ?: 0;
		$limits = FreshRSS_Context::systemConf()->limits;
		$cookie_duration = empty($limits['cookie_duration']) ? FreshRSS_Auth::DEFAULT_COOKIE_DURATION : $limits['cookie_duration'];
		if ($mtime + $cookie_duration < time()) {
			// Token has expired (> cookie_duration) or does not exist.
			@unlink($token_file);
			return [];
		}

		$credentials = @file_get_contents($token_file);
		if ($credentials !== false && self::renewCookie($token)) {
			return explode("\t", $credentials, 2);
		}
		return [];
	}

	/** @return string|false */
	private static function renewCookie(string $token) {
		$token_file = DATA_PATH . '/tokens/' . $token . '.txt';
		if (touch($token_file)) {
			$limits = FreshRSS_Context::systemConf()->limits;
			$cookie_duration = empty($limits['cookie_duration']) ? FreshRSS_Auth::DEFAULT_COOKIE_DURATION : $limits['cookie_duration'];
			$expire = time() + $cookie_duration;
			Minz_Session::setLongTermCookie('FreshRSS_login', $token, $expire);
			return $token;
		}
		return false;
	}

	/** @return string|false */
	public static function makeCookie(string $username, string $password_hash) {
		do {
			$token = sha1(FreshRSS_Context::systemConf()->salt . $username . uniqid('' . mt_rand(), true));
			$token_file = DATA_PATH . '/tokens/' . $token . '.txt';
		} while (file_exists($token_file));

		if (@file_put_contents($token_file, $username . "\t" . $password_hash) === false) {
			return false;
		}

		return self::renewCookie($token);
	}

	public static function deleteCookie(): void {
		$token = Minz_Session::getLongTermCookie('FreshRSS_login');
		if (ctype_alnum($token)) {
			Minz_Session::deleteLongTermCookie('FreshRSS_login');
			@unlink(DATA_PATH . '/tokens/' . $token . '.txt');
		}

		if (rand(0, 10) === 1) {
			self::purgeTokens();
		}
	}

	public static function purgeTokens(): void {
		$limits = FreshRSS_Context::systemConf()->limits;
		$cookie_duration = empty($limits['cookie_duration']) ? FreshRSS_Auth::DEFAULT_COOKIE_DURATION : $limits['cookie_duration'];
		$oldest = time() - $cookie_duration;
		foreach (new DirectoryIterator(DATA_PATH . '/tokens/') as $file_info) {
			$extension = $file_info->getExtension();
			if ($extension === 'txt' && $file_info->getMTime() < $oldest) {
				@unlink($file_info->getPathname());
			}
		}
	}
}
Log.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Log.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_Log extends Minz_Model {

	private string $date;
	private string $level;
	private string $information;

	public function date(): string {
		return $this->date;
	}
	public function level(): string {
		return $this->level;
	}
	public function info(): string {
		return $this->information;
	}
	public function _date(string $date): void {
		$this->date = $date;
	}
	public function _level(string $level): void {
		$this->level = $level;
	}
	public function _info(string $information): void {
		$this->information = $information;
	}
}
LogDAO.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/LogDAO.php'
View Content
<?php
declare(strict_types=1);

final class FreshRSS_LogDAO {
	public static function logPath(?string $logFileName = null): string {
		if ($logFileName === null || $logFileName === '') {
			$logFileName = LOG_FILENAME;
		}
		return USERS_PATH . '/' . (Minz_User::name() ?? Minz_User::INTERNAL_USER) . '/' . $logFileName;
	}

	/** @return array<FreshRSS_Log> */
	public static function lines(?string $logFileName = null): array {
		$logs = [];
		$handle = @fopen(self::logPath($logFileName), 'r');
		if ($handle) {
			while (($line = fgets($handle)) !== false) {
				if (preg_match('/^\[([^\[]+)\] \[([^\[]+)\] --- (.*)$/', $line, $matches)) {
					$myLog = new FreshRSS_Log();
					$myLog->_date($matches[1]);
					$myLog->_level($matches[2]);
					$myLog->_info($matches[3]);
					$logs[] = $myLog;
				}
			}
			fclose($handle);
		}
		return array_reverse($logs);
	}

	public static function truncate(?string $logFileName = null): void {
		file_put_contents(self::logPath($logFileName), '');
		if (FreshRSS_Auth::hasAccess('admin')) {
			file_put_contents(ADMIN_LOG, '');
			file_put_contents(API_LOG, '');
			file_put_contents(PSHB_LOG, '');
		}
	}
}
ReadingMode.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/ReadingMode.php'
View Content
<?php
declare(strict_types=1);

/**
 * Manage the reading modes in FreshRSS.
 */
class FreshRSS_ReadingMode {

	protected string $id;
	protected string $name;
	protected string $title;
	/** @var array{c:string,a:string,params:array<string,mixed>} */
	protected array $urlParams;
	protected bool $isActive = false;

	/**
	 * ReadingMode constructor.
	 * @param array{c:string,a:string,params:array<string,mixed>} $urlParams
	 */
	public function __construct(string $id, string $title, array $urlParams, bool $active) {
		$this->id = $id;
		$this->name = _i($id);
		$this->title = $title;
		$this->urlParams = $urlParams;
		$this->isActive = $active;
	}

	public function getId(): string {
		return $this->id;
	}

	public function getName(): string {
		return $this->name;
	}

	public function setName(string $name): FreshRSS_ReadingMode {
		$this->name = $name;
		return $this;
	}

	public function getTitle(): string {
		return $this->title;
	}

	public function setTitle(string $title): FreshRSS_ReadingMode {
		$this->title = $title;
		return $this;
	}

	/** @return array{c:string,a:string,params:array<string,mixed>} */
	public function getUrlParams(): array {
		return $this->urlParams;
	}

	/** @param array{c:string,a:string,params:array<string,mixed>} $urlParams */
	public function setUrlParams(array $urlParams): FreshRSS_ReadingMode {
		$this->urlParams = $urlParams;
		return $this;
	}

	public function isActive(): bool {
		return $this->isActive;
	}

	public function setIsActive(bool $isActive): FreshRSS_ReadingMode {
		$this->isActive = $isActive;
		return $this;
	}

	/**
	 * @return array<FreshRSS_ReadingMode> the built-in reading modes
	 */
	public static function getReadingModes(): array {
		$actualView = Minz_Request::actionName();
		$defaultCtrl = Minz_Request::defaultControllerName();
		$isDefaultCtrl = Minz_Request::controllerName() === $defaultCtrl;
		$urlOutput = Minz_Request::currentRequest();

		$readingModes = [
			new FreshRSS_ReadingMode(
				"view-normal",
				_t('index.menu.normal_view'),
				array_merge($urlOutput, ['c' => $defaultCtrl, 'a' => 'normal']),
				($isDefaultCtrl && $actualView === 'normal')
			),
			new FreshRSS_ReadingMode(
				"view-global",
				_t('index.menu.global_view'),
				array_merge($urlOutput, ['c' => $defaultCtrl, 'a' => 'global']),
				($isDefaultCtrl && $actualView === 'global')
			),
			new FreshRSS_ReadingMode(
				"view-reader",
				_t('index.menu.reader_view'),
				array_merge($urlOutput, ['c' => $defaultCtrl, 'a' => 'reader']),
				($isDefaultCtrl && $actualView === 'reader')
			)
		];

		return $readingModes;
	}
}
Search.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Search.php'
View Content
<?php
declare(strict_types=1);

require_once(LIB_PATH . '/lib_date.php');

/**
 * Contains a search from the search form.
 *
 * It allows to extract meaningful bits of the search and store them in a
 * convenient object
 */
class FreshRSS_Search {

	/**
	 * This contains the user input string
	 */
	private string $raw_input = '';

	// The following properties are extracted from the raw input
	/** @var array<string>|null */
	private ?array $entry_ids = null;
	/** @var array<int>|null */
	private ?array $feed_ids = null;
	/** @var array<int>|'*'|null */
	private $label_ids = null;
	/** @var array<string>|null */
	private ?array $label_names = null;
	/** @var array<string>|null */
	private ?array $intitle = null;
	/** @var int|false|null */
	private $min_date = null;
	/** @var int|false|null */
	private $max_date = null;
	/** @var int|false|null */
	private $min_pubdate = null;
	/** @var int|false|null */
	private $max_pubdate = null;
	/** @var array<string>|null */
	private ?array $inurl = null;
	/** @var array<string>|null */
	private ?array $author = null;
	/** @var array<string>|null */
	private ?array $tags = null;
	/** @var array<string>|null */
	private ?array $search = null;

	/** @var array<string>|null */
	private ?array $not_entry_ids = null;
	/** @var array<int>|null */
	private ?array $not_feed_ids = null;
	/** @var array<int>|'*'|null */
	private $not_label_ids = null;
	/** @var array<string>|null */
	private ?array $not_label_names = null;
	/** @var array<string>|null */
	private ?array $not_intitle = null;
	/** @var int|false|null */
	private $not_min_date = null;
	/** @var int|false|null */
	private $not_max_date = null;
	/** @var int|false|null */
	private $not_min_pubdate = null;
	/** @var int|false|null */
	private $not_max_pubdate = null;
	/** @var array<string>|null */
	private ?array $not_inurl = null;
	/** @var array<string>|null */
	private ?array $not_author = null;
	/** @var array<string>|null */
	private ?array $not_tags = null;
	/** @var array<string>|null */
	private ?array $not_search = null;

	public function __construct(string $input) {
		$input = self::cleanSearch($input);
		$input = self::unescape($input);
		$this->raw_input = $input;

		$input = $this->parseNotEntryIds($input);
		$input = $this->parseNotFeedIds($input);
		$input = $this->parseNotLabelIds($input);
		$input = $this->parseNotLabelNames($input);

		$input = $this->parseNotPubdateSearch($input);
		$input = $this->parseNotDateSearch($input);

		$input = $this->parseNotIntitleSearch($input);
		$input = $this->parseNotAuthorSearch($input);
		$input = $this->parseNotInurlSearch($input);
		$input = $this->parseNotTagsSearch($input);

		$input = $this->parseEntryIds($input);
		$input = $this->parseFeedIds($input);
		$input = $this->parseLabelIds($input);
		$input = $this->parseLabelNames($input);

		$input = $this->parsePubdateSearch($input);
		$input = $this->parseDateSearch($input);

		$input = $this->parseIntitleSearch($input);
		$input = $this->parseAuthorSearch($input);
		$input = $this->parseInurlSearch($input);
		$input = $this->parseTagsSearch($input);

		$input = $this->parseQuotedSearch($input);
		$input = $this->parseNotSearch($input);
		$this->parseSearch($input);
	}

	#[\Override]
	public function __toString(): string {
		return $this->getRawInput();
	}

	public function getRawInput(): string {
		return $this->raw_input;
	}

	/** @return array<string>|null */
	public function getEntryIds(): ?array {
		return $this->entry_ids;
	}
	/** @return array<string>|null */
	public function getNotEntryIds(): ?array {
		return $this->not_entry_ids;
	}

	/** @return array<int>|null */
	public function getFeedIds(): ?array {
		return $this->feed_ids;
	}
	/** @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;
	}
	/** @return array<string>|null */
	public function getLabelNames(): ?array {
		return $this->label_names;
	}
	/** @return array<string>|null */
	public function getNotLabelNames(): ?array {
		return $this->not_label_names;
	}

	/** @return array<string>|null */
	public function getIntitle(): ?array {
		return $this->intitle;
	}
	/** @return array<string>|null */
	public function getNotIntitle(): ?array {
		return $this->not_intitle;
	}

	public function getMinDate(): ?int {
		return $this->min_date ?: null;
	}
	public function getNotMinDate(): ?int {
		return $this->not_min_date ?: null;
	}
	public function setMinDate(int $value): void {
		$this->min_date = $value;
	}

	public function getMaxDate(): ?int {
		return $this->max_date ?: null;
	}
	public function getNotMaxDate(): ?int {
		return $this->not_max_date ?: null;
	}
	public function setMaxDate(int $value): void {
		$this->max_date = $value;
	}

	public function getMinPubdate(): ?int {
		return $this->min_pubdate ?: null;
	}
	public function getNotMinPubdate(): ?int {
		return $this->not_min_pubdate ?: null;
	}

	public function getMaxPubdate(): ?int {
		return $this->max_pubdate ?: null;
	}
	public function getNotMaxPubdate(): ?int {
		return $this->not_max_pubdate ?: null;
	}

	/** @return array<string>|null */
	public function getInurl(): ?array {
		return $this->inurl;
	}
	/** @return array<string>|null */
	public function getNotInurl(): ?array {
		return $this->not_inurl;
	}

	/** @return array<string>|null */
	public function getAuthor(): ?array {
		return $this->author;
	}
	/** @return array<string>|null */
	public function getNotAuthor(): ?array {
		return $this->not_author;
	}

	/** @return array<string>|null */
	public function getTags(): ?array {
		return $this->tags;
	}
	/** @return array<string>|null */
	public function getNotTags(): ?array {
		return $this->not_tags;
	}

	/** @return array<string>|null */
	public function getSearch(): ?array {
		return $this->search;
	}
	/** @return array<string>|null */
	public function getNotSearch(): ?array {
		return $this->not_search;
	}

	/**
	 * @param array<string>|null $anArray
	 * @return array<string>
	 */
	private static function removeEmptyValues(?array $anArray): array {
		return empty($anArray) ? [] : array_filter($anArray, static fn(string $value) => $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--) {
				$value[$i] = self::decodeSpaces($value[$i]);
			}
		} else {
			$value = trim(str_replace('+', ' ', $value));
		}
		return $value;
	}

	/**
	 * Parse the search string to find entry (article) IDs.
	 */
	private function parseEntryIds(string $input): string {
		if (preg_match_all('/\be:(?P<search>[0-9,]*)/', $input, $matches)) {
			$input = str_replace($matches[0], '', $input);
			$ids_lists = $matches['search'];
			$this->entry_ids = [];
			foreach ($ids_lists as $ids_list) {
				$entry_ids = explode(',', $ids_list);
				$entry_ids = self::removeEmptyValues($entry_ids);
				if (!empty($entry_ids)) {
					$this->entry_ids = array_merge($this->entry_ids, $entry_ids);
				}
			}
		}
		return $input;
	}

	private function parseNotEntryIds(string $input): string {
		if (preg_match_all('/(?<=\s|^)[!-]e:(?P<search>[0-9,]*)/', $input, $matches)) {
			$input = str_replace($matches[0], '', $input);
			$ids_lists = $matches['search'];
			$this->not_entry_ids = [];
			foreach ($ids_lists as $ids_list) {
				$entry_ids = explode(',', $ids_list);
				$entry_ids = self::removeEmptyValues($entry_ids);
				if (!empty($entry_ids)) {
					$this->not_entry_ids = array_merge($this->not_entry_ids, $entry_ids);
				}
			}
		}
		return $input;
	}

	private function parseFeedIds(string $input): string {
		if (preg_match_all('/\bf:(?P<search>[0-9,]*)/', $input, $matches)) {
			$input = str_replace($matches[0], '', $input);
			$ids_lists = $matches['search'];
			$this->feed_ids = [];
			foreach ($ids_lists as $ids_list) {
				$feed_ids = explode(',', $ids_list);
				$feed_ids = self::removeEmptyValues($feed_ids);
				/** @var array<int> $feed_ids */
				$feed_ids = array_map('intval', $feed_ids);
				if (!empty($feed_ids)) {
					$this->feed_ids = array_merge($this->feed_ids, $feed_ids);
				}
			}
		}
		return $input;
	}

	private function parseNotFeedIds(string $input): string {
		if (preg_match_all('/(?<=\s|^)[!-]f:(?P<search>[0-9,]*)/', $input, $matches)) {
			$input = str_replace($matches[0], '', $input);
			$ids_lists = $matches['search'];
			$this->not_feed_ids = [];
			foreach ($ids_lists as $ids_list) {
				$feed_ids = explode(',', $ids_list);
				$feed_ids = self::removeEmptyValues($feed_ids);
				/** @var array<int> $feed_ids */
				$feed_ids = array_map('intval', $feed_ids);
				if (!empty($feed_ids)) {
					$this->not_feed_ids = array_merge($this->not_feed_ids, $feed_ids);
				}
			}
		}
		return $input;
	}

	/**
	 * Parse the search string to find tags (labels) IDs.
	 */
	private function parseLabelIds(string $input): string {
		if (preg_match_all('/\b[lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) {
			$input = str_replace($matches[0], '', $input);
			$ids_lists = $matches['search'];
			$this->label_ids = [];
			foreach ($ids_lists as $ids_list) {
				if ($ids_list === '*') {
					$this->label_ids = '*';
					break;
				}
				$label_ids = explode(',', $ids_list);
				$label_ids = self::removeEmptyValues($label_ids);
				/** @var array<int> $label_ids */
				$label_ids = array_map('intval', $label_ids);
				if (!empty($label_ids)) {
					$this->label_ids = array_merge($this->label_ids, $label_ids);
				}
			}
		}
		return $input;
	}

	private function parseNotLabelIds(string $input): string {
		if (preg_match_all('/(?<=\s|^)[!-][lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) {
			$input = str_replace($matches[0], '', $input);
			$ids_lists = $matches['search'];
			$this->not_label_ids = [];
			foreach ($ids_lists as $ids_list) {
				if ($ids_list === '*') {
					$this->not_label_ids = '*';
					break;
				}
				$label_ids = explode(',', $ids_list);
				$label_ids = self::removeEmptyValues($label_ids);
				/** @var array<int> $label_ids */
				$label_ids = array_map('intval', $label_ids);
				if (!empty($label_ids)) {
					$this->not_label_ids = array_merge($this->not_label_ids, $label_ids);
				}
			}
		}
		return $input;
	}

	/**
	 * Parse the search string to find tags (labels) names.
	 */
	private function parseLabelNames(string $input): string {
		$names_lists = [];
		if (preg_match_all('/\blabels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
			$names_lists = $matches['search'];
			$input = str_replace($matches[0], '', $input);
		}
		if (preg_match_all('/\blabels?:(?P<search>[^\s"]*)/', $input, $matches)) {
			$names_lists = array_merge($names_lists, $matches['search']);
			$input = str_replace($matches[0], '', $input);
		}
		if (!empty($names_lists)) {
			$this->label_names = [];
			foreach ($names_lists as $names_list) {
				$names_array = explode(',', $names_list);
				$names_array = self::removeEmptyValues($names_array);
				if (!empty($names_array)) {
					$this->label_names = array_merge($this->label_names, $names_array);
				}
			}
		}
		return $input;
	}

	/**
	 * Parse the search string to find tags (labels) names to exclude.
	 */
	private function parseNotLabelNames(string $input): string {
		$names_lists = [];
		if (preg_match_all('/(?<=\s|^)[!-]labels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
			$names_lists = $matches['search'];
			$input = str_replace($matches[0], '', $input);
		}
		if (preg_match_all('/(?<=\s|^)[!-]labels?:(?P<search>[^\s"]*)/', $input, $matches)) {
			$names_lists = array_merge($names_lists, $matches['search']);
			$input = str_replace($matches[0], '', $input);
		}
		if (!empty($names_lists)) {
			$this->not_label_names = [];
			foreach ($names_lists as $names_list) {
				$names_array = explode(',', $names_list);
				$names_array = self::removeEmptyValues($names_array);
				if (!empty($names_array)) {
					$this->not_label_names = array_merge($this->not_label_names, $names_array);
				}
			}
		}
		return $input;
	}

	/**
	 * Parse the search string to find intitle keyword and the search related to it.
	 * The search is the first word following the keyword.
	 */
	private function parseIntitleSearch(string $input): string {
		if (preg_match_all('/\bintitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
			$this->intitle = $matches['search'];
			$input = str_replace($matches[0], '', $input);
		}
		if (preg_match_all('/\bintitle:(?P<search>[^\s"]*)/', $input, $matches)) {
			$this->intitle = array_merge($this->intitle ?: [], $matches['search']);
			$input = str_replace($matches[0], '', $input);
		}
		$this->intitle = self::removeEmptyValues($this->intitle);
		if (empty($this->intitle)) {
			$this->intitle = null;
		}
		return $input;
	}

	private function parseNotIntitleSearch(string $input): string {
		if (preg_match_all('/(?<=\s|^)[!-]intitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
			$this->not_intitle = $matches['search'];
			$input = str_replace($matches[0], '', $input);
		}
		if (preg_match_all('/(?<=\s|^)[!-]intitle:(?P<search>[^\s"]*)/', $input, $matches)) {
			$this->not_intitle = array_merge($this->not_intitle ?: [], $matches['search']);
			$input = str_replace($matches[0], '', $input);
		}
		$this->not_intitle = self::removeEmptyValues($this->not_intitle);
		if (empty($this->not_intitle)) {
			$this->not_intitle = null;
		}
		return $input;
	}

	/**
	 * Parse the search string to find author keyword and the search related to it.
	 * The search is the first word following the keyword except when using
	 * a delimiter. Supported delimiters are single quote (') and double quotes (").
	 */
	private function parseAuthorSearch(string $input): string {
		if (preg_match_all('/\bauthor:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
			$this->author = $matches['search'];
			$input = str_replace($matches[0], '', $input);
		}
		if (preg_match_all('/\bauthor:(?P<search>[^\s"]*)/', $input, $matches)) {
			$this->author = array_merge($this->author ?: [], $matches['search']);
			$input = str_replace($matches[0], '', $input);
		}
		$this->author = self::removeEmptyValues($this->author);
		if (empty($this->author)) {
			$this->author = null;
		}
		return $input;
	}

	private function parseNotAuthorSearch(string $input): string {
		if (preg_match_all('/(?<=\s|^)[!-]author:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
			$this->not_author = $matches['search'];
			$input = str_replace($matches[0], '', $input);
		}
		if (preg_match_all('/(?<=\s|^)[!-]author:(?P<search>[^\s"]*)/', $input, $matches)) {
			$this->not_author = array_merge($this->not_author ?: [], $matches['search']);
			$input = str_replace($matches[0], '', $input);
		}
		$this->not_author = self::removeEmptyValues($this->not_author);
		if (empty($this->not_author)) {
			$this->not_author = null;
		}
		return $input;
	}

	/**
	 * Parse the search string to find inurl keyword and the search related to it.
	 * The search is the first word following the keyword.
	 */
	private function parseInurlSearch(string $input): string {
		if (preg_match_all('/\binurl:(?P<search>[^\s]*)/', $input, $matches)) {
			$this->inurl = $matches['search'];
			$input = str_replace($matches[0], '', $input);
			$this->inurl = self::removeEmptyValues($this->inurl);
		}
		return $input;
	}

	private function parseNotInurlSearch(string $input): string {
		if (preg_match_all('/(?<=\s|^)[!-]inurl:(?P<search>[^\s]*)/', $input, $matches)) {
			$this->not_inurl = $matches['search'];
			$input = str_replace($matches[0], '', $input);
			$this->not_inurl = self::removeEmptyValues($this->not_inurl);
		}
		return $input;
	}

	/**
	 * Parse the search string to find date keyword and the search related to it.
	 * The search is the first word following the keyword.
	 */
	private function parseDateSearch(string $input): string {
		if (preg_match_all('/\bdate:(?P<search>[^\s]*)/', $input, $matches)) {
			$input = str_replace($matches[0], '', $input);
			$dates = self::removeEmptyValues($matches['search']);
			if (!empty($dates[0])) {
				[$this->min_date, $this->max_date] = parseDateInterval($dates[0]);
			}
		}
		return $input;
	}

	private function parseNotDateSearch(string $input): string {
		if (preg_match_all('/(?<=\s|^)[!-]date:(?P<search>[^\s]*)/', $input, $matches)) {
			$input = str_replace($matches[0], '', $input);
			$dates = self::removeEmptyValues($matches['search']);
			if (!empty($dates[0])) {
				[$this->not_min_date, $this->not_max_date] = parseDateInterval($dates[0]);
			}
		}
		return $input;
	}


	/**
	 * Parse the search string to find pubdate keyword and the search related to it.
	 * The search is the first word following the keyword.
	 */
	private function parsePubdateSearch(string $input): string {
		if (preg_match_all('/\bpubdate:(?P<search>[^\s]*)/', $input, $matches)) {
			$input = str_replace($matches[0], '', $input);
			$dates = self::removeEmptyValues($matches['search']);
			if (!empty($dates[0])) {
				[$this->min_pubdate, $this->max_pubdate] = parseDateInterval($dates[0]);
			}
		}
		return $input;
	}

	private function parseNotPubdateSearch(string $input): string {
		if (preg_match_all('/(?<=\s|^)[!-]pubdate:(?P<search>[^\s]*)/', $input, $matches)) {
			$input = str_replace($matches[0], '', $input);
			$dates = self::removeEmptyValues($matches['search']);
			if (!empty($dates[0])) {
				[$this->not_min_pubdate, $this->not_max_pubdate] = parseDateInterval($dates[0]);
			}
		}
		return $input;
	}

	/**
	 * Parse the search string to find tags keyword (# followed by a word)
	 * and the search related to it.
	 * The search is the first word following the #.
	 */
	private function parseTagsSearch(string $input): string {
		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 parseNotTagsSearch(string $input): string {
		if (preg_match_all('/(?<=\s|^)[!-]#(?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;
	}

	/**
	 * Parse the search string to find search values.
	 * Every word is a distinct search value using a delimiter.
	 * Supported delimiters are single quote (') and double quotes (").
	 */
	private function parseQuotedSearch(string $input): string {
		$input = self::cleanSearch($input);
		if ($input === '') {
			return '';
		}
		if (preg_match_all('/(?<![!-])(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
			$this->search = $matches['search'];
			//TODO: Replace all those str_replace with PREG_OFFSET_CAPTURE
			$input = str_replace($matches[0], '', $input);
		}
		return $input;
	}

	/**
	 * Parse the search string to find search values.
	 * Every word is a distinct search value.
	 */
	private function parseSearch(string $input): string {
		$input = self::cleanSearch($input);
		if ($input === '') {
			return '';
		}
		if (is_array($this->search)) {
			$this->search = array_merge($this->search, explode(' ', $input));
		} else {
			$this->search = explode(' ', $input);
		}
		return $input;
	}

	private function parseNotSearch(string $input): string {
		$input = self::cleanSearch($input);
		if ($input === '') {
			return '';
		}
		if (preg_match_all('/(?<=\s|^)[!-](?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
			$this->not_search = $matches['search'];
			$input = str_replace($matches[0], '', $input);
		}
		$input = self::cleanSearch($input);
		if ($input === '') {
			return '';
		}
		if (preg_match_all('/(?<=\s|^)[!-](?P<search>[^\s]+)/', $input, $matches)) {
			$this->not_search = array_merge(is_array($this->not_search) ? $this->not_search : [], $matches['search']);
			$input = str_replace($matches[0], '', $input);
		}
		$this->not_search = self::removeEmptyValues($this->not_search);
		return $input;
	}

	/**
	 * Remove all unnecessary spaces in the search
	 */
	private static function cleanSearch(string $input): string {
		$input = preg_replace('/\s+/', ' ', $input);
		if (!is_string($input)) {
			return '';
		}
		return trim($input);
	}

	/** Remove escaping backslashes for parenthesis logic */
	private static function unescape(string $input): string {
		return str_replace(['\\(', '\\)'], ['(', ')'], $input);
	}
}
Share.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Share.php'
View Content
<?php
declare(strict_types=1);

/**
 * Manage the sharing options in FreshRSS.
 */
class FreshRSS_Share {
	/**
	 * The list of available sharing options.
	 * @var array<string,FreshRSS_Share>
	 */
	private static array $list_sharing = [];

	/**
	 * Register a new sharing option.
	 * @param array{'type':string,'url':string,'transform'?:array<callable>|array<string,array<callable>>,'field'?:string,'help'?:string,'form'?:'simple'|'advanced',
	 *	'method'?:'GET'|'POST','HTMLtag'?:'button','deprecated'?:bool} $share_options is an array defining the share option.
	 */
	public static function register(array $share_options): void {
		$type = $share_options['type'];
		if (isset(self::$list_sharing[$type])) {
			return;
		}

		self::$list_sharing[$type] = new FreshRSS_Share(
			$type,
			$share_options['url'],
			$share_options['transform'] ?? [],
			$share_options['form'] ?? 'simple',
			$share_options['help'] ?? '',
			$share_options['method'] ?? 'GET',
			$share_options['field'] ?? null,
			$share_options['HTMLtag'] ?? null,
			$share_options['deprecated'] ?? false
		);
	}

	/**
	 * Register sharing options in a file.
	 * @param string $filename the name of the file to load.
	 */
	public static function load(string $filename): void {
		$shares_from_file = @include($filename);
		if (!is_array($shares_from_file)) {
			$shares_from_file = [];
		}

		foreach ($shares_from_file as $share_type => $share_options) {
			$share_options['type'] = $share_type;
			self::register($share_options);
		}

		uasort(self::$list_sharing, static fn(FreshRSS_Share $a, FreshRSS_Share $b) => strcasecmp($a->name() ?? '', $b->name() ?? ''));
	}

	/**
	 * Return the list of sharing options.
	 * @return array<string,FreshRSS_Share>
	 */
	public static function enum(): array {
		return self::$list_sharing;
	}

	/**
	 * @param string $type the share type, null if $type is not registered.
	 * @return FreshRSS_Share|null object related to the given type.
	 */
	public static function get(string $type): ?FreshRSS_Share {
		return self::$list_sharing[$type] ?? null;
	}


	private string $type;
	private string $name;
	private string $url_transform;
	/** @var array<callable>|array<string,array<callable>> */
	private array $transforms;
	/**
	 * @phpstan-var 'simple'|'advanced'
	 */
	private string $form_type;
	private string $help_url;
	private ?string $custom_name = null;
	private ?string $base_url = null;
	private ?string $id = null;
	private ?string $title = null;
	private ?string $link = null;
	private bool $isDeprecated;
	/**
	 * @phpstan-var 'GET'|'POST'
	 */
	private string $method;
	private ?string $field;
	/**
	 * @phpstan-var 'button'|null
	 */
	private ?string $HTMLtag;

	/**
	 * Create a FreshRSS_Share object.
	 * @param string $type is a unique string defining the kind of share option.
	 * @param string $url_transform defines the url format to use in order to share.
	 * @param array<callable>|array<string,array<callable>> $transforms is an array of transformations to apply on link and title.
	 * @param 'simple'|'advanced' $form_type defines which form we have to use to complete. "simple"
	 *        is typically for a centralized service while "advanced" is for
	 *        decentralized ones.
	 * @param string $help_url is an optional url to give help on this option.
	 * @param 'GET'|'POST' $method defines the sharing method (GET or POST)
	 * @param string|null $field
	 * @param 'button'|null $HTMLtag
	 * @param bool $isDeprecated
	 */
	private function __construct(string $type, string $url_transform, array $transforms, string $form_type,
		string $help_url, string $method, ?string $field, ?string $HTMLtag, bool $isDeprecated = false) {
		$this->type = $type;
		$this->name = _t('gen.share.' . $type);
		$this->url_transform = $url_transform;
		$this->help_url = $help_url;
		$this->HTMLtag = $HTMLtag;
		$this->isDeprecated = $isDeprecated;
		$this->transforms = $transforms;

		if (!in_array($form_type, ['simple', 'advanced'], true)) {
			$form_type = 'simple';
		}
		$this->form_type = $form_type;
		if (!in_array($method, ['GET', 'POST'], true)) {
			$method = 'GET';
		}
		$this->method = $method;
		$this->field = $field;
	}

	/**
	 * Update a FreshRSS_Share object with information from an array.
	 * @param array<string,string> $options is a list of information to update where keys should be
	 *        in this list: name, url, id, title, link.
	 */
	public function update(array $options): void {
		foreach ($options as $key => $value) {
			switch ($key) {
				case 'name':
					$this->custom_name = $value;
					break;
				case 'url':
					$this->base_url = $value;
					break;
				case 'id':
					$this->id = $value;
					break;
				case 'title':
					$this->title = $value;
					break;
				case 'link':
					$this->link = $value;
					break;
				case 'method':
					$this->method = strcasecmp($value, 'POST') === 0 ? 'POST' : 'GET';
					break;
				case 'field':
					$this->field = $value;
					break;
			}
		}
	}

	/**
	 * Return the current type of the share option.
	 */
	public function type(): string {
		return $this->type;
	}

	/**
	 * Return the current method of the share option.
	 * @return 'GET'|'POST'
	 */
	public function method(): string {
		return $this->method;
	}

	/**
	 * Return the current field of the share option. It’s null for shares
	 * using the GET method.
	 */
	public function field(): ?string {
		return $this->field;
	}

	/**
	 * Return the current form type of the share option.
	 * @return 'simple'|'advanced'
	 */
	public function formType(): string {
		return $this->form_type;
	}

	/**
	 * Return the current help url of the share option.
	 */
	public function help(): string {
		return $this->help_url;
	}

	/**
	 * Return the custom type of HTML tag of the share option, null for default.
	 * @return 'button'|null
	 */
	public function HTMLtag(): ?string {
		return $this->HTMLtag;
	}

	/**
	 * Return the current name of the share option.
	 */
	public function name(bool $real = false): ?string {
		if ($real || empty($this->custom_name)) {
			return $this->name;
		} else {
			return $this->custom_name;
		}
	}

	/**
	 * Return the current base url of the share option.
	 */
	public function baseUrl(): string {
		return $this->base_url ?? '';
	}

	/**
	 * Return the deprecated status of the share option.
	 */
	public function isDeprecated(): bool {
		return $this->isDeprecated;
	}

	/**
	 * Return the current url by merging url_transform and base_url.
	 */
	public function url(): string {
		$matches = [
			'~ID~',
			'~URL~',
			'~TITLE~',
			'~LINK~',
		];
		$replaces = [
			$this->id(),
			$this->base_url,
			$this->title(),
			$this->link(),
		];
		return str_replace($matches, $replaces, $this->url_transform);
	}

	/**
	 * Return the id.
	 * @param bool $raw true if we should get the id without transformations.
	 */
	public function id(bool $raw = false): ?string {
		if ($raw) {
			return $this->id;
		}

		if ($this->id === null) {
			return null;
		}
		return self::transform($this->id, $this->getTransform('id'));
	}

	/**
	 * Return the title.
	 * @param bool $raw true if we should get the title without transformations.
	 */
	public function title(bool $raw = false): string {
		if ($raw) {
			return $this->title ?? '';
		}

		if ($this->title === null) {
			return '';
		}
		return self::transform($this->title, $this->getTransform('title'));
	}

	/**
	 * Return the link.
	 * @param bool $raw true if we should get the link without transformations.
	 */
	public function link(bool $raw = false): string {
		if ($raw) {
			return $this->link ?? '';
		}
		if ($this->link === null) {
			return '';
		}

		return self::transform($this->link, $this->getTransform('link'));
	}

	/**
	 * Transform a data with the given functions.
	 * @param string $data the data to transform.
	 * @param array<callable> $transform an array containing a list of functions to apply.
	 * @return string the transformed data.
	 */
	private static function transform(string $data, array $transform): string {
		if (empty($transform)) {
			return $data;
		}

		foreach ($transform as $action) {
			$data = call_user_func($action, $data);
		}

		return $data;
	}

	/**
	 * Get the list of transformations for the given attribute.
	 * @param string $attr the attribute of which we want the transformations.
	 * @return array<callable> containing a list of transformations to apply.
	 */
	private function getTransform(string $attr): array {
		if (array_key_exists($attr, $this->transforms)) {
			$candidates = is_array($this->transforms[$attr]) ? $this->transforms[$attr] : [];
		} else {
			$candidates = $this->transforms;
		}

		$transforms = [];
		foreach ($candidates as $transform) {
			if (is_callable($transform)) {
				$transforms[] = $transform;
			}
		}
		return $transforms;
	}
}
StatsDAO.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/StatsDAO.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_StatsDAO extends Minz_ModelPdo {

	public const ENTRY_COUNT_PERIOD = 30;

	protected function sqlFloor(string $s): string {
		return "FLOOR($s)";
	}

	/**
	 * Calculates entry repartition for all feeds and for main stream.
	 *
	 * @return array{'main_stream':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false,'all_feeds':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false}
	 */
	public function calculateEntryRepartition(): array {
		return [
			'main_stream' => $this->calculateEntryRepartitionPerFeed(null, true),
			'all_feeds' => $this->calculateEntryRepartitionPerFeed(null, false),
		];
	}

	/**
	 * Calculates entry repartition for the selection.
	 * The repartition includes:
	 *   - total entries
	 *   - read entries
	 *   - unread entries
	 *   - favorite entries
	 *
	 * @return array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false
	 */
	public function calculateEntryRepartitionPerFeed(?int $feed = null, bool $only_main = false) {
		$filter = '';
		if ($only_main) {
			$filter .= 'AND f.priority = 10';
		}
		if (!is_null($feed)) {
			$filter .= "AND e.id_feed = {$feed}";
		}
		$sql = <<<SQL
SELECT COUNT(1) AS total,
COUNT(1) - SUM(e.is_read) AS count_unreads,
SUM(e.is_read) AS count_reads,
SUM(e.is_favorite) AS count_favorites
FROM `_entry` AS e, `_feed` AS f
WHERE e.id_feed = f.id
{$filter}
SQL;
		$res = $this->fetchAssoc($sql);
		if (!empty($res[0])) {
			$dao = $res[0];
			/** @var array<array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}> $res */
			FreshRSS_DatabaseDAO::pdoInt($dao, ['total', 'count_unreads', 'count_reads', 'count_favorites']);
			return $dao;
		}
		return false;
	}

	/**
	 * Calculates entry count per day on a 30 days period.
	 * @return array<int,int>
	 */
	public function calculateEntryCount(): array {
		$count = $this->initEntryCountArray();
		$midnight = mktime(0, 0, 0) ?: 0;
		$oldest = $midnight - (self::ENTRY_COUNT_PERIOD * 86400);

		// Get stats per day for the last 30 days
		$sqlDay = $this->sqlFloor("(date - $midnight) / 86400");
		$sql = <<<SQL
SELECT {$sqlDay} AS day,
COUNT(*) as count
FROM `_entry`
WHERE date >= {$oldest} AND date < {$midnight}
GROUP BY day
ORDER BY day ASC
SQL;
		$res = $this->fetchAssoc($sql);
		if ($res == false) {
			return [];
		}
		/** @var array<array{'day':int,'count':int}> $res */
		foreach ($res as $value) {
			$count[(int)($value['day'])] = (int)($value['count']);
		}
		return $count;
	}

	/**
	 * Initialize an array for the entry count.
	 * @return array<int,int>
	 */
	protected function initEntryCountArray(): array {
		return $this->initStatsArray(-self::ENTRY_COUNT_PERIOD, -1);
	}

	/**
	 * Calculates the number of article per hour of the day per feed
	 * @return array<int,int>
	 */
	public function calculateEntryRepartitionPerFeedPerHour(?int $feed = null): array {
		return $this->calculateEntryRepartitionPerFeedPerPeriod('%H', $feed);
	}

	/**
	 * Calculates the number of article per day of week per feed
	 * @return array<int,int>
	 */
	public function calculateEntryRepartitionPerFeedPerDayOfWeek(?int $feed = null): array {
		return $this->calculateEntryRepartitionPerFeedPerPeriod('%w', $feed);
	}

	/**
	 * Calculates the number of article per month per feed
	 * @return array<int,int>
	 */
	public function calculateEntryRepartitionPerFeedPerMonth(?int $feed = null): array {
		$monthRepartition = $this->calculateEntryRepartitionPerFeedPerPeriod('%m', $feed);
		// cut out the 0th month (Jan=1, Dec=12)
		\array_splice($monthRepartition, 0, 1);
		return $monthRepartition;
	}


	/**
	 * Calculates the number of article per period per feed
	 * @param string $period format string to use for grouping
	 * @return array<int,int>
	 */
	protected function calculateEntryRepartitionPerFeedPerPeriod(string $period, ?int $feed = null): array {
		$restrict = '';
		if ($feed) {
			$restrict = "WHERE e.id_feed = {$feed}";
		}
		$sql = <<<SQL
SELECT DATE_FORMAT(FROM_UNIXTIME(e.date), '{$period}') AS period
, COUNT(1) AS count
FROM `_entry` AS e
{$restrict}
GROUP BY period
ORDER BY period ASC
SQL;

		$res = $this->fetchAssoc($sql);
		if ($res == false) {
			return [];
		}
		switch ($period) {
			case '%H':
				$periodMax = 24;
				break;
			case '%w':
				$periodMax = 7;
				break;
			case '%m':
				$periodMax = 12;
				break;
			default:
				$periodMax = 30;
		}

		$repartition = array_fill(0, $periodMax, 0);
		foreach ($res as $value) {
			$repartition[(int)$value['period']] = (int)$value['count'];
		}

		return $repartition;
	}

	/**
	 * Calculates the average number of article per hour per feed
	 */
	public function calculateEntryAveragePerFeedPerHour(?int $feed = null): float {
		return $this->calculateEntryAveragePerFeedPerPeriod(1 / 24, $feed);
	}

	/**
	 * Calculates the average number of article per day of week per feed
	 */
	public function calculateEntryAveragePerFeedPerDayOfWeek(?int $feed = null): float {
		return $this->calculateEntryAveragePerFeedPerPeriod(7, $feed);
	}

	/**
	 * Calculates the average number of article per month per feed
	 */
	public function calculateEntryAveragePerFeedPerMonth(?int $feed = null): float {
		return $this->calculateEntryAveragePerFeedPerPeriod(30, $feed);
	}

	/**
	 * Calculates the average number of article per feed
	 * @param float $period number used to divide the number of day in the period
	 */
	protected function calculateEntryAveragePerFeedPerPeriod(float $period, ?int $feed = null): float {
		$restrict = '';
		if ($feed) {
			$restrict = "WHERE e.id_feed = {$feed}";
		}
		$sql = <<<SQL
SELECT COUNT(1) AS count
, MIN(date) AS date_min
, MAX(date) AS date_max
FROM `_entry` AS e
{$restrict}
SQL;
		$res = $this->fetchAssoc($sql);
		if ($res == null || empty($res[0])) {
			return -1.0;
		}
		$date_min = new \DateTime();
		$date_min->setTimestamp((int)($res[0]['date_min']));
		$date_max = new \DateTime();
		$date_max->setTimestamp((int)($res[0]['date_max']));
		$interval = $date_max->diff($date_min, true);
		$interval_in_days = (float)($interval->format('%a'));
		if ($interval_in_days <= 0) {
			// Surely only one article.
			// We will return count / (period/period) == count.
			$interval_in_days = $period;
		}

		return (int)$res[0]['count'] / ($interval_in_days / $period);
	}

	/**
	 * Initialize an array for statistics depending on a range
	 * @return array<int,int>
	 */
	protected function initStatsArray(int $min, int $max): array {
		return array_map(fn() => 0, array_flip(range($min, $max)));
	}

	/**
	 * Calculates feed count per category.
	 * @return array<array{'label':string,'data':int}>
	 */
	public function calculateFeedByCategory(): array {
		$sql = <<<SQL
SELECT c.name AS label
, COUNT(f.id) AS data
FROM `_category` AS c, `_feed` AS f
WHERE c.id = f.category
GROUP BY label
ORDER BY data DESC
SQL;
		/** @var array<array{'label':string,'data':int}>|null @res */
		$res = $this->fetchAssoc($sql);
		return $res == null ? [] : $res;
	}

	/**
	 * Calculates entry count per category.
	 * @return array<array{'label':string,'data':int}>
	 */
	public function calculateEntryByCategory(): array {
		$sql = <<<SQL
SELECT c.name AS label
, COUNT(e.id) AS data
FROM `_category` AS c, `_feed` AS f, `_entry` AS e
WHERE c.id = f.category
AND f.id = e.id_feed
GROUP BY label
ORDER BY data DESC
SQL;
		$res = $this->fetchAssoc($sql);
		/** @var array<array{'label':string,'data':int}>|null $res */
		return $res == null ? [] : $res;
	}

	/**
	 * Calculates the 10 top feeds based on their number of entries
	 * @return array<array{'id':int,'name':string,'category':string,'count':int}>
	 */
	public function calculateTopFeed(): array {
		$sql = <<<SQL
SELECT f.id AS id
, MAX(f.name) AS name
, MAX(c.name) AS category
, COUNT(e.id) AS count
FROM `_category` AS c, `_feed` AS f, `_entry` AS e
WHERE c.id = f.category
AND f.id = e.id_feed
GROUP BY f.id
ORDER BY count DESC
LIMIT 10
SQL;
		$res = $this->fetchAssoc($sql);
		/** @var array<array{'id':int,'name':string,'category':string,'count':int}>|null $res */
		if (is_array($res)) {
			foreach ($res as &$dao) {
				FreshRSS_DatabaseDAO::pdoInt($dao, ['id', 'count']);
			}
			return $res;
		}
		return [];
	}

	/**
	 * Calculates the last publication date for each feed
	 * @return array<array{'id':int,'name':string,'last_date':int,'nb_articles':int}>
	 */
	public function calculateFeedLastDate(): array {
		$sql = <<<SQL
SELECT MAX(f.id) as id
, MAX(f.name) AS name
, MAX(date) AS last_date
, COUNT(*) AS nb_articles
FROM `_feed` AS f, `_entry` AS e
WHERE f.id = e.id_feed
GROUP BY f.id
ORDER BY name
SQL;
		$res = $this->fetchAssoc($sql);
		/** @var array<array{'id':int,'name':string,'last_date':int,'nb_articles':int}>|null $res */
		if (is_array($res)) {
			foreach ($res as &$dao) {
				FreshRSS_DatabaseDAO::pdoInt($dao, ['id', 'last_date', 'nb_articles']);
			}
			return $res;
		}
		return [];
	}

	/**
	 * Gets days ready for graphs
	 * @return array<string>
	 */
	public function getDays(): array {
		return $this->convertToTranslatedJson([
			'sun',
			'mon',
			'tue',
			'wed',
			'thu',
			'fri',
			'sat',
		]);
	}

	/**
	 * Gets months ready for graphs
	 * @return array<string>
	 */
	public function getMonths(): array {
		return $this->convertToTranslatedJson([
			'jan',
			'feb',
			'mar',
			'apr',
			'may_',
			'jun',
			'jul',
			'aug',
			'sep',
			'oct',
			'nov',
			'dec',
		]);
	}

	/**
	 * Translates array content
	 * @param array<string> $data
	 * @return array<string>
	 */
	private function convertToTranslatedJson(array $data = []): array {
		$translated = array_map(static fn(string $a) => _t('gen.date.' . $a), $data);

		return $translated;
	}
}
StatsDAOPGSQL.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/StatsDAOPGSQL.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_StatsDAOPGSQL extends FreshRSS_StatsDAO {

	/**
	 * Calculates the number of article per hour of the day per feed
	 *
	 * @param int $feed id
	 * @return array<int,int>
	 */
	#[\Override]
	public function calculateEntryRepartitionPerFeedPerHour(?int $feed = null): array {
		return $this->calculateEntryRepartitionPerFeedPerPeriod('hour', $feed);
	}

	/**
	 * Calculates the number of article per day of week per feed
	 * @return array<int,int>
	 */
	#[\Override]
	public function calculateEntryRepartitionPerFeedPerDayOfWeek(?int $feed = null): array {
		return $this->calculateEntryRepartitionPerFeedPerPeriod('day', $feed);
	}

	/**
	 * Calculates the number of article per month per feed
	 * @return array<int,int>
	 */
	#[\Override]
	public function calculateEntryRepartitionPerFeedPerMonth(?int $feed = null): array {
		return $this->calculateEntryRepartitionPerFeedPerPeriod('month', $feed);
	}

	/**
	 * Calculates the number of article per period per feed
	 * @param string $period format string to use for grouping
	 * @return array<int,int>
	 */
	#[\Override]
	protected function calculateEntryRepartitionPerFeedPerPeriod(string $period, ?int $feed = null): array {
		$restrict = '';
		if ($feed) {
			$restrict = "WHERE e.id_feed = {$feed}";
		}
		$sql = <<<SQL
SELECT extract( {$period} from to_timestamp(e.date)) AS period
, COUNT(1) AS count
FROM `_entry` AS e
{$restrict}
GROUP BY period
ORDER BY period ASC
SQL;

		$res = $this->fetchAssoc($sql);
		if ($res == null) {
			return [];
		}

		switch ($period) {
			case 'hour':
				$periodMax = 24;
				break;
			case 'day':
				$periodMax = 7;
				break;
			case 'month':
				$periodMax = 12;
				break;
			default:
				$periodMax = 30;
		}

		$repartition = array_fill(0, $periodMax, 0);
		foreach ($res as $value) {
			$repartition[(int)$value['period']] = (int)$value['count'];
		}

		return $repartition;
	}
}
StatsDAOSQLite.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/StatsDAOSQLite.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_StatsDAOSQLite extends FreshRSS_StatsDAO {

	#[\Override]
	protected function sqlFloor(string $s): string {
		return "CAST(($s) AS INT)";
	}

	/**
	 * @return array<int,int>
	 */
	#[\Override]
	protected function calculateEntryRepartitionPerFeedPerPeriod(string $period, ?int $feed = null): array {
		if ($feed) {
			$restrict = "WHERE e.id_feed = {$feed}";
		} else {
			$restrict = '';
		}
		$sql = <<<SQL
SELECT strftime('{$period}', e.date, 'unixepoch') AS period
, COUNT(1) AS count
FROM `_entry` AS e
{$restrict}
GROUP BY period
ORDER BY period ASC
SQL;

		$res = $this->fetchAssoc($sql);
		if ($res == null) {
			return [];
		}

		switch ($period) {
			case '%H':
				$periodMax = 24;
				break;
			case '%w':
				$periodMax = 7;
				break;
			case '%m':
				$periodMax = 12;
				break;
			default:
				$periodMax = 30;
		}

		$repartition = array_fill(0, $periodMax, 0);
		foreach ($res as $value) {
			$repartition[(int)$value['period']] = (int)$value['count'];
		}

		return $repartition;
	}
}
SystemConfiguration.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/SystemConfiguration.php'
View Content
<?php
declare(strict_types=1);

/**
 * @property bool $allow_anonymous
 * @property bool $allow_anonymous_refresh
 * @property-read bool $allow_referrer
 * @property bool $allow_robots
 * @property bool $api_enabled
 * @property string $archiving
 * @property 'form'|'http_auth'|'none' $auth_type
 * @property string $auto_update_url
 * @property-read array<int,mixed> $curl_options
 * @property string $default_user
 * @property string $email_validation_token
 * @property bool $force_email_validation
 * @property-read bool $http_auth_auto_register
 * @property-read string $http_auth_auto_register_email_field
 * @property string $language
 * @property array<string,int> $limits
 * @property-read string $logo_html
 * @property-read string $meta_description
 * @property-read int $nb_parallel_refresh
 * @property-read bool $pubsubhubbub_enabled
 * @property-read string $salt
 * @property-read bool $simplepie_syslog_enabled
 * @property bool $unsafe_autologin_enabled
 * @property array<string> $trusted_sources
 * @property array<string,array<string,mixed>> $extensions
 */
final class FreshRSS_SystemConfiguration extends Minz_Configuration {

	/** @throws Minz_FileNotExistException */
	public static function init(string $config_filename, ?string $default_filename = null): FreshRSS_SystemConfiguration {
		parent::register('system', $config_filename, $default_filename);
		try {
			return parent::get('system');
		} catch (Minz_ConfigurationNamespaceException $ex) {
			FreshRSS::killApp($ex->getMessage());
		}
	}
}
Tag.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Tag.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_Tag extends Minz_Model {
	use FreshRSS_AttributesTrait, FreshRSS_FilterActionsTrait;

	private int $id = 0;
	private string $name;
	private int $nbEntries = -1;
	private int $nbUnread = -1;

	public function __construct(string $name = '') {
		$this->_name($name);
	}

	public function id(): int {
		return $this->id;
	}

	/**
	 * @param int|string $value
	 */
	public function _id($value): void {
		$this->id = (int)$value;
	}

	public function name(): string {
		return $this->name;
	}

	public function _name(string $value): void {
		$this->name = trim($value);
	}

	public function nbEntries(): int {
		if ($this->nbEntries < 0) {
			$tagDAO = FreshRSS_Factory::createTagDao();
			$this->nbEntries = $tagDAO->countEntries($this->id()) ?: 0;
		}
		return $this->nbEntries;
	}

	/**
	 * @param string|int $value
	 */
	public function _nbEntries($value): void {
		$this->nbEntries = (int)$value;
	}

	public function nbUnread(): int {
		if ($this->nbUnread < 0) {
			$tagDAO = FreshRSS_Factory::createTagDao();
			$this->nbUnread = $tagDAO->countNotRead($this->id()) ?: 0;
		}
		return $this->nbUnread;
	}

	/**
	 * @param string|int $value
	 */
	public function _nbUnread($value): void {
		$this->nbUnread = (int)$value;
	}
}
TagDAO.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/TagDAO.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_TagDAO extends Minz_ModelPdo {

	public function sqlIgnore(): string {
		return 'IGNORE';
	}

	/**
	 * @param array{'id'?:int,'name':string,'attributes'?:array<string,mixed>} $valuesTmp
	 * @return int|false
	 */
	public function addTag(array $valuesTmp) {
		// TRIM() gives a text type hint to PostgreSQL
		// No category of the same name
		$sql = <<<'SQL'
INSERT INTO `_tag`(name, attributes)
SELECT * FROM (SELECT TRIM(?) as name, TRIM(?) as attributes) t2
WHERE NOT EXISTS (SELECT 1 FROM `_category` WHERE name = TRIM(?))
SQL;
		$stm = $this->pdo->prepare($sql);

		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
		if (!isset($valuesTmp['attributes'])) {
			$valuesTmp['attributes'] = [];
		}
		$values = [
			$valuesTmp['name'],
			is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
			$valuesTmp['name'],
		];

		if ($stm !== false && $stm->execute($values) && $stm->rowCount() > 0) {
			$tagId = $this->pdo->lastInsertId('`_tag_id_seq`');
			return $tagId === false ? false : (int)$tagId;
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/** @return int|false */
	public function addTagObject(FreshRSS_Tag $tag) {
		$tag0 = $this->searchByName($tag->name());
		if (!$tag0) {
			$values = [
				'name' => $tag->name(),
				'attributes' => $tag->attributes(),
			];
			return $this->addTag($values);
		}
		return $tag->id();
	}

	/** @return int|false */
	public function updateTagName(int $id, string $name) {
		// No category of the same name
		$sql = <<<'SQL'
UPDATE `_tag` SET name = :name1 WHERE id = :id
AND NOT EXISTS (SELECT 1 FROM `_category` WHERE name = :name2)
SQL;

		$name = mb_strcut(trim($name), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
		$stm = $this->pdo->prepare($sql);
		if ($stm !== false &&
			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
			$stm->bindValue(':name1', $name, PDO::PARAM_STR) &&
			$stm->bindValue(':name2', $name, PDO::PARAM_STR) &&
			$stm->execute()) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/**
	 * @param array<string,mixed> $attributes
	 * @return int|false
	 */
	public function updateTagAttributes(int $id, array $attributes) {
		$sql = 'UPDATE `_tag` SET attributes=:attributes WHERE id=:id';
		$stm = $this->pdo->prepare($sql);
		if ($stm !== false &&
			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
			$stm->bindValue(':attributes', json_encode($attributes, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PDO::PARAM_STR) &&
			$stm->execute()) {
			return $stm->rowCount();
		}
		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
		return false;
	}

	/**
	 * @param non-empty-string $key
	 * @param mixed $value
	 * @return int|false
	 */
	public function updateTagAttribute(FreshRSS_Tag $tag, string $key, $value) {
		$tag->_attribute($key, $value);
		return $this->updateTagAttributes($tag->id(), $tag->attributes());
	}

	/**
	 * @return int|false
	 */
	public function deleteTag(int $id) {
		if ($id <= 0) {
			return false;
		}
		$sql = 'DELETE FROM `_tag` WHERE id=?';
		$stm = $this->pdo->prepare($sql);

		$values = [$id];

		if ($stm !== false && $stm->execute($values)) {
			return $stm->rowCount();
		} else {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/** @return Traversable<array{'id':int,'name':string,'attributes'?:array<string,mixed>}> */
	public function selectAll(): Traversable {
		$sql = 'SELECT id, name, attributes FROM `_tag`';
		$stm = $this->pdo->query($sql);
		if ($stm === false) {
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
			return;
		}
		while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
			/** @var array{'id':int,'name':string,'attributes'?:array<string,mixed>} $row */
			yield $row;
		}
	}

	/** @return Traversable<array{'id_tag':int,'id_entry':string}> */
	public function selectEntryTag(): Traversable {
		$sql = 'SELECT id_tag, id_entry FROM `_entrytag`';
		$stm = $this->pdo->query($sql);
		if ($stm === false) {
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
			return;
		}
		while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
			FreshRSS_DatabaseDAO::pdoInt($row, ['id_tag']);
			FreshRSS_DatabaseDAO::pdoString($row, ['id_entry']);
			yield $row;
		}
	}

	/** @return int|false */
	public function updateEntryTag(int $oldTagId, int $newTagId) {
		$sql = <<<'SQL'
DELETE FROM `_entrytag` WHERE EXISTS (
	SELECT 1 FROM `_entrytag` AS e
	WHERE e.id_entry = `_entrytag`.id_entry AND e.id_tag = ? AND `_entrytag`.id_tag = ?)
SQL;
		$stm = $this->pdo->prepare($sql);

		if ($stm === false || !$stm->execute([$newTagId, $oldTagId])) {
			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
			return false;
		}

		$sql = 'UPDATE `_entrytag` SET id_tag = ? WHERE id_tag = ?';
		$stm = $this->pdo->prepare($sql);

		if ($stm !== false && $stm->execute([$newTagId, $oldTagId])) {
			return $stm->rowCount();
		}
		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
		Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
		return false;
	}

	public function searchById(int $id): ?FreshRSS_Tag {
		$res = $this->fetchAssoc('SELECT * FROM `_tag` WHERE id=:id', [':id' => $id]);
		/** @var array<array{'id':int,'name':string,'attributes'?:string}>|null $res */
		return $res === null ? null : (current(self::daoToTags($res)) ?: null);
	}

	public function searchByName(string $name): ?FreshRSS_Tag {
		$res = $this->fetchAssoc('SELECT * FROM `_tag` WHERE name=:name', [':name' => $name]);
		/** @var array<array{'id':int,'name':string,'attributes'?:string}>|null $res */
		return $res === null ? null : (current(self::daoToTags($res)) ?: null);
	}

	/** @return array<int,FreshRSS_Tag>|false */
	public function listTags(bool $precounts = false) {
		if ($precounts) {
			$sql = <<<'SQL'
SELECT t.id, t.name, count(e.id) AS unreads
FROM `_tag` t
LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id
LEFT OUTER JOIN `_entry` e ON et.id_entry = e.id AND e.is_read = 0
GROUP BY t.id
ORDER BY t.name
SQL;
		} else {
			$sql = 'SELECT * FROM `_tag` ORDER BY name';
		}

		$stm = $this->pdo->query($sql);
		if ($stm !== false) {
			$res = $stm->fetchAll(PDO::FETCH_ASSOC) ?: [];
			return self::daoToTags($res);
		} else {
			$info = $this->pdo->errorInfo();
			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
			return false;
		}
	}

	/** @return array<string,string> */
	public function listTagsNewestItemUsec(?int $id_tag = null): array {
		$sql = <<<'SQL'
SELECT t.id AS id_tag, MAX(e.id) AS newest_item_us
FROM `_tag` t
LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id
LEFT OUTER JOIN `_entry` e ON et.id_entry = e.id
SQL;
		if ($id_tag === null) {
			$sql .= ' GROUP BY t.id';
		} else {
			$sql .= ' WHERE t.id=' . $id_tag;
		}
		$res = $this->fetchAssoc($sql);
		if ($res == null) {
			return [];
		}
		$newestItemUsec = [];
		foreach ($res as $line) {
			$newestItemUsec['t_' . $line['id_tag']] = (string)($line['newest_item_us']);
		}
		return $newestItemUsec;
	}

	public function count(): int {
		$sql = 'SELECT COUNT(*) AS count FROM `_tag`';
		$stm = $this->pdo->query($sql);
		if ($stm !== false) {
			$res = $stm->fetchAll(PDO::FETCH_ASSOC);
			return (int)$res[0]['count'];
		}
		$info = $this->pdo->errorInfo();
		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
		return -1;
	}

	public function countEntries(int $id): int {
		$sql = 'SELECT COUNT(*) AS count FROM `_entrytag` WHERE id_tag=:id_tag';
		$res = $this->fetchAssoc($sql, [':id_tag' => $id]);
		if ($res == null || !isset($res[0]['count'])) {
			return -1;
		}
		return (int)$res[0]['count'];
	}

	public function countNotRead(?int $id = null): int {
		$sql = <<<'SQL'
SELECT COUNT(*) AS count FROM `_entrytag` et
INNER JOIN `_entry` e ON et.id_entry=e.id
WHERE e.is_read=0
SQL;
		$values = [];
		if (null !== $id) {
			$sql .= ' AND et.id_tag=:id_tag';
			$values[':id_tag'] = $id;
		}

		$res = $this->fetchAssoc($sql, $values);
		if ($res == null || !isset($res[0]['count'])) {
			return -1;
		}
		return (int)$res[0]['count'];
	}

	public function tagEntry(int $id_tag, string $id_entry, bool $checked = true): bool {
		if ($checked) {
			$sql = 'INSERT ' . $this->sqlIgnore() . ' INTO `_entrytag`(id_tag, id_entry) VALUES(?, ?)';
		} else {
			$sql = 'DELETE FROM `_entrytag` WHERE id_tag=? AND id_entry=?';
		}
		$stm = $this->pdo->prepare($sql);
		$values = [$id_tag, $id_entry];

		if ($stm !== false && $stm->execute($values)) {
			return true;
		}
		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
		return false;
	}

	/**
	 * @param array<array{id_tag:int,id_entry:string}> $addLabels Labels to insert as batch
	 * @return int|false Number of new entries or false in case of error
	 */
	public function tagEntries(array $addLabels) {
		$hasValues = false;
		$sql = 'INSERT ' . $this->sqlIgnore() . ' INTO `_entrytag`(id_tag, id_entry) VALUES ';
		foreach ($addLabels as $addLabel) {
			$id_tag = (int)($addLabel['id_tag'] ?? 0);
			$id_entry = $addLabel['id_entry'] ?? '';
			if ($id_tag > 0 && ctype_digit($id_entry)) {
				$sql .= "({$id_tag},{$id_entry}),";
				$hasValues = true;
			}
		}
		$sql = rtrim($sql, ',');
		if (!$hasValues) {
			return false;
		}

		$affected = $this->pdo->exec($sql);
		if ($affected !== false) {
			return $affected;
		}
		$info = $this->pdo->errorInfo();
		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
		return false;
	}

	/**
	 * @return array<int,array{'id':int,'name':string,'id_entry':string,'checked':bool}>|false
	 */
	public function getTagsForEntry(string $id_entry) {
		$sql = <<<'SQL'
SELECT t.id, t.name, et.id_entry IS NOT NULL as checked
FROM `_tag` t
LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id AND et.id_entry=?
ORDER BY t.name
SQL;

		$stm = $this->pdo->prepare($sql);
		$values = [$id_entry];

		if ($stm !== false && $stm->execute($values)) {
			$lines = $stm->fetchAll(PDO::FETCH_ASSOC);
			for ($i = count($lines) - 1; $i >= 0; $i--) {
				$lines[$i]['id'] = (int)($lines[$i]['id']);
				$lines[$i]['checked'] = !empty($lines[$i]['checked']);
			}
			return $lines;
		}
		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
		return false;
	}

	/**
	 * @param array<FreshRSS_Entry|numeric-string|array<string,string>> $entries
	 * @return array<array{'id_entry':string,'id_tag':int,'name':string}>|false
	 */
	public function getTagsForEntries(array $entries) {
		$sql = <<<'SQL'
SELECT et.id_entry, et.id_tag, t.name
FROM `_tag` t
INNER JOIN `_entrytag` et ON et.id_tag = t.id
SQL;

		$values = [];
		if (count($entries) > 0) {
			if (count($entries) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
				// Split a query with too many variables parameters
				$idsChunks = array_chunk($entries, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
				foreach ($idsChunks as $idsChunk) {
					$valuesChunk = $this->getTagsForEntries($idsChunk);
					if (!is_array($valuesChunk)) {
						return false;
					}
					$values = array_merge($values, $valuesChunk);
				}
				return $values;
			}
			$sql .= ' AND et.id_entry IN (' . str_repeat('?,', count($entries) - 1) . '?)';
			if (is_array($entries[0])) {
				/** @var array<array<string,string>> $entries */
				foreach ($entries as $entry) {
					if (!empty($entry['id'])) {
						$values[] = $entry['id'];
					}
				}
			} elseif (is_object($entries[0])) {
				/** @var array<FreshRSS_Entry> $entries */
				foreach ($entries as $entry) {
					$values[] = $entry->id();
				}
			} else {
				/** @var array<numeric-string> $entries */
				foreach ($entries as $entry) {
					$values[] = $entry;
				}
			}
		}
		$stm = $this->pdo->prepare($sql);

		if ($stm !== false && $stm->execute($values)) {
			return $stm->fetchAll(PDO::FETCH_ASSOC);
		}
		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
		return false;
	}

	/**
	 * Produces an array: for each entry ID (prefixed by `e_`), associate a list of labels.
	 * Used by API and by JSON export, to speed up queries (would be very expensive to perform a label look-up on each entry individually).
	 * @param array<FreshRSS_Entry|numeric-string> $entries the list of entries for which to retrieve the labels.
	 * @return array<string,array<string>> An array of the shape `[e_id_entry => ["label 1", "label 2"]]`
	 */
	public function getEntryIdsTagNames(array $entries): array {
		$result = [];
		foreach ($this->getTagsForEntries($entries) ?: [] as $line) {
			$entryId = 'e_' . $line['id_entry'];
			$tagName = $line['name'];
			if (empty($result[$entryId])) {
				$result[$entryId] = [];
			}
			$result[$entryId][] = $tagName;
		}
		return $result;
	}

	/**
	 * @param iterable<array{'id':int,'name':string,'attributes'?:string}> $listDAO
	 * @return array<int,FreshRSS_Tag>
	 */
	private static function daoToTags(iterable $listDAO): array {
		$list = [];
		foreach ($listDAO as $dao) {
			if (empty($dao['id']) || empty($dao['name'])) {
				continue;
			}
			$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[$tag->id()] = $tag;
		}
		return $list;
	}
}
TagDAOPGSQL.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/TagDAOPGSQL.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_TagDAOPGSQL extends FreshRSS_TagDAO {

	#[\Override]
	public function sqlIgnore(): string {
		return '';	//TODO
	}
}
TagDAOSQLite.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/TagDAOSQLite.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_TagDAOSQLite extends FreshRSS_TagDAO {

	#[\Override]
	public function sqlIgnore(): string {
		return 'OR IGNORE';
	}
}
Themes.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/Themes.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_Themes extends Minz_Model {

	private static string $themesUrl = '/themes/';
	private static string $defaultIconsUrl = '/themes/icons/';
	public static string $defaultTheme = 'Origine';

	/** @return array<string> */
	public static function getList(): array {
		return array_values(array_diff(
			scandir(PUBLIC_PATH . self::$themesUrl) ?: [],
			['..', '.']
		));
	}

	/** @return array<string,array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}}> */
	public static function get(): array {
		$themes_list = self::getList();
		$list = [];
		foreach ($themes_list as $theme_dir) {
			$theme = self::get_infos($theme_dir);
			if ($theme) {
				$list[$theme_dir] = $theme;
			}
		}
		return $list;
	}

	/**
	 * @return false|array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}}
	 */
	public static function get_infos(string $theme_id) {
		$theme_dir = PUBLIC_PATH . self::$themesUrl . $theme_id;
		if (is_dir($theme_dir)) {
			$json_filename = $theme_dir . '/metadata.json';
			if (file_exists($json_filename)) {
				$content = file_get_contents($json_filename) ?: '';
				$res = json_decode($content, true);
				if (is_array($res) &&
						!empty($res['name']) &&
						isset($res['files']) &&
						is_array($res['files'])) {
					$res['id'] = $theme_id;
					/** @var array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}} */
					return $res;
				}
			}
		}
		return false;
	}

	private static string $themeIconsUrl;
	/** @var array<string,int> */
	private static array $themeIcons;

	/**
	 * @return false|array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}}
	 */
	public static function load(string $theme_id) {
		$infos = self::get_infos($theme_id);
		if (!$infos) {
			if ($theme_id !== self::$defaultTheme) {	//Fall-back to default theme
				return self::load(self::$defaultTheme);
			}
			$themes_list = self::getList();
			if (!empty($themes_list)) {
				if ($theme_id !== $themes_list[0]) {	//Fall-back to first theme
					return self::load($themes_list[0]);
				}
			}
			return false;
		}
		self::$themeIconsUrl = self::$themesUrl . $theme_id . '/icons/';
		self::$themeIcons = is_dir(PUBLIC_PATH . self::$themeIconsUrl) ? array_fill_keys(array_diff(
			scandir(PUBLIC_PATH . self::$themeIconsUrl) ?: [],
			['..', '.']
		), 1) : [];
		return $infos;
	}

	public static function title(string $name): string {
		static $titles = [
			'opml-dyn' => 'sub.category.dynamic_opml',
		];
		return $titles[$name] ?? '';
	}

	public static function alt(string $name): string {
		static $alts = [
			'add' => '➕',	//✚
			'all' => '☰',
			'bookmark-add' => '➕',	//✚
			'bookmark-tag' => '📑',
			'category' => '🗂️',	//☷
			'close' => '❌',
			'configure' => '⚙️',
			'debug' => '🐛',
			'down' => '🔽',	//▽
			'error' => '❌',
			'favorite' => '⭐',	//★
			'FreshRSS-logo' => '⊚',
			'help' => 'ℹ️',	//ⓘ
			'icon' => '⊚',
			'important' => '📌',
			'key' => '🔑',	//⚿
			'label' => '🏷️',
			'link' => '↗️',	//↗
			'look' => '👀',	//👁
			'login' => '🔒',
			'logout' => '🔓',
			'next' => '⏩',
			'non-starred' => '☆',
			'notice' => 'ℹ️',	//ⓘ
			'opml-dyn' => '⚡',
			'prev' => '⏪',
			'read' => '☑️',	//☑
			'rss' => '📣',	//☄
			'unread' => '🔲',	//☐
			'refresh' => '🔃',	//↻
			'search' => '🔍',
			'share' => '♻️',	//♺
			'sort-down' => '⬇️',	//↓
			'sort-up' => '⬆️',	//↑
			'starred' => '⭐',	//★
			'stats' => '📈',	//%
			'tag' => '🔖',	//⚐
			'up' => '🔼',	//△
			'view-normal' => '📰',	//☰
			'view-global' => '📖',	//☷
			'view-reader' => '📜',
			'warning' => '⚠️',	//△
		];
		return $alts[$name] ?? '';
	}

	// TODO: Change for enum in PHP 8.1+
	public const ICON_DEFAULT = 0;
	public const ICON_IMG = 1;
	public const ICON_URL = 2;
	public const ICON_EMOJI = 3;

	public static function icon(string $name, int $type = self::ICON_DEFAULT): string {
		$alt = self::alt($name);
		if ($alt == '') {
			return '';
		}

		$url = $name . '.svg';
		$url = isset(self::$themeIcons[$url]) ? (self::$themeIconsUrl . $url) : (self::$defaultIconsUrl . $url);

		$title = self::title($name);
		if ($title != '') {
			$title = ' title="' . _t($title) . '"';
		}

		if ($type == self::ICON_DEFAULT) {
			if ((FreshRSS_Context::hasUserConf() && FreshRSS_Context::userConf()->icons_as_emojis)
				// default to emoji alternate for some icons
			) {
				$type = self::ICON_EMOJI;
			} else {
				$type = self::ICON_IMG;
			}
		}

		switch ($type) {
			case self::ICON_URL:
				return Minz_Url::display($url);
			case self::ICON_IMG:
				return '<img class="icon" src="' . Minz_Url::display($url) . '" loading="lazy" alt="' . $alt . '"' . $title . ' />';
			case self::ICON_EMOJI:
			default:
				return '<span class="icon"' . $title . '>' . $alt . '</span>';
		}
	}
}
UserConfiguration.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/UserConfiguration.php'
View Content
<?php
declare(strict_types=1);

/**
 * @property string $apiPasswordHash
 * @property array{'keep_period':string|false,'keep_max':int|false,'keep_min':int|false,'keep_favourites':bool,'keep_labels':bool,'keep_unreads':bool} $archiving
 * @property bool $auto_load_more
 * @property bool $auto_remove_article
 * @property bool $bottomline_date
 * @property bool $bottomline_favorite
 * @property bool $bottomline_link
 * @property bool $bottomline_read
 * @property bool $bottomline_sharing
 * @property bool $bottomline_tags
 * @property bool $bottomline_myLabels
 * @property string $content_width
 * @property-read int $default_state
 * @property string $default_view
 * @property string|bool $display_categories
 * @property string $show_tags
 * @property int $show_tags_max
 * @property string $show_author_date
 * @property string $show_feed_name
 * @property string $show_article_icons
 * @property bool $display_posts
 * @property string $email_validation_token
 * @property-read bool $enabled
 * @property string $feverKey
 * @property bool $hide_read_feeds
 * @property int $html5_notif_timeout
 * @property-read bool $is_admin
 * @property int|null $keep_history_default
 * @property string $language
 * @property string $timezone
 * @property bool $lazyload
 * @property string $mail_login
 * @property bool $mark_updated_article_unread
 * @property array<string,bool|int> $mark_when
 * @property int $max_posts_per_rss
 * @property-read array<string,int> $limits
 * @property int|null $old_entries
 * @property bool $onread_jump_next
 * @property string $passwordHash
 * @property int $posts_per_page
 * @property array<array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string,'token'?:string}> $queries
 * @property bool $reading_confirm
 * @property int $since_hours_posts_per_rss
 * @property bool $show_fav_unread
 * @property bool $show_favicons
 * @property bool $icons_as_emojis
 * @property int $simplify_over_n_feeds
 * @property bool $show_nav_buttons
 * @property 'ASC'|'DESC' $sort_order
 * @property array<string,array<string>> $sharing
 * @property array<string,string> $shortcuts
 * @property bool $sides_close_article
 * @property bool $sticky_post
 * @property string $theme
 * @property string $darkMode
 * @property string $token
 * @property bool $topline_date
 * @property bool $topline_display_authors
 * @property bool $topline_favorite
 * @property bool $topline_sharing
 * @property bool $topline_link
 * @property bool $topline_read
 * @property bool $topline_summary
 * @property string $topline_website
 * @property string $topline_thumbnail
 * @property int $ttl_default
 * @property int $dynamic_opml_ttl_default
 * @property-read bool $unsafe_autologin_enabled
 * @property string $view_mode
 * @property array<string,bool|int|string> $volatile
 * @property array<string,array<string,mixed>> $extensions
 */
final class FreshRSS_UserConfiguration extends Minz_Configuration {
	use FreshRSS_FilterActionsTrait;

	/** @throws Minz_FileNotExistException */
	public static function init(string $config_filename, ?string $default_filename = null): FreshRSS_UserConfiguration {
		parent::register('user', $config_filename, $default_filename);
		try {
			return parent::get('user');
		} catch (Minz_ConfigurationNamespaceException $ex) {
			FreshRSS::killApp($ex->getMessage());
		}
	}

	/**
	 * Access the default configuration for users.
	 * @throws Minz_FileNotExistException
	 */
	public static function default(): FreshRSS_UserConfiguration {
		static $default_user_conf = null;
		if ($default_user_conf == null) {
			$namespace = 'user_default';
			FreshRSS_UserConfiguration::register($namespace, '_', FRESHRSS_PATH . '/config-user.default.php');
			$default_user_conf = FreshRSS_UserConfiguration::get($namespace);
		}
		return $default_user_conf;
	}

	/**
	 * @param non-empty-string $key
	 * @return array<int|string,mixed>|null
	 */
	public function attributeArray(string $key): ?array {
		$a = parent::param($key, null);
		return is_array($a) ? $a : null;
	}

	/** @param non-empty-string $key */
	public function attributeBool(string $key): ?bool {
		$a = parent::param($key, null);
		return is_bool($a) ? $a : null;
	}

	/** @param non-empty-string $key */
	public function attributeInt(string $key): ?int {
		$a = parent::param($key, null);
		return is_numeric($a) ? (int)$a : null;
	}

	/** @param non-empty-string $key */
	public function attributeString(string $key): ?string {
		$a = parent::param($key, null);
		return is_string($a) ? $a : null;
	}

	/**
	 * @param non-empty-string $key
	 * @param array<string,mixed>|mixed|null $value Value, not HTML-encoded
	 */
	public function _attribute(string $key, $value = null): void {
		parent::_param($key, $value);
	}
}
UserDAO.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/UserDAO.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_UserDAO extends Minz_ModelPdo {

	public function createUser(): bool {
		require(APP_PATH . '/SQL/install.sql.' . $this->pdo->dbType() . '.php');

		try {
			$sql = $GLOBALS['SQL_CREATE_TABLES'];
			$ok = $this->pdo->exec($sql) !== false;	//Note: Only exec() can take multiple statements safely.
		} catch (Exception $e) {
			$ok = false;
			Minz_Log::error('Error while creating database for user ' . $this->current_user . ': ' . $e->getMessage());
		}

		if ($ok) {
			return true;
		} else {
			$info = $this->pdo->errorInfo();
			Minz_Log::error(__METHOD__ . ' error: ' . json_encode($info));
			return false;
		}
	}

	public function deleteUser(): bool {
		if (defined('STDERR')) {
			fwrite(STDERR, 'Deleting SQL data for user “' . $this->current_user . "”…\n");
		}

		require(APP_PATH . '/SQL/install.sql.' . $this->pdo->dbType() . '.php');
		$ok = $this->pdo->exec($GLOBALS['SQL_DROP_TABLES']) !== false;

		if ($ok) {
			$this->close();
			return true;
		} else {
			$info = $this->pdo->errorInfo();
			Minz_Log::error(__METHOD__ . ' error: ' . json_encode($info));
			return false;
		}
	}

	public static function exists(string $username): bool {
		return is_dir(USERS_PATH . '/' . $username);
	}

	/** Update time of the last modification action by the user (e.g., mark an article as read) */
	public static function touch(string $username = ''): bool {
		if ($username === '') {
			$username = Minz_User::name() ?? Minz_User::INTERNAL_USER;
		} elseif (!FreshRSS_user_Controller::checkUsername($username)) {
			return false;
		}
		return touch(USERS_PATH . '/' . $username . '/config.php');
	}

	/** Time of the last modification action by the user (e.g., mark an article as read) */
	public static function mtime(string $username): int {
		return @filemtime(USERS_PATH . '/' . $username . '/config.php') ?: 0;
	}

	/** Update time of the last new content automatically received by the user (e.g., cron job, WebSub) */
	public static function ctouch(string $username = ''): bool {
		if ($username === '') {
			$username = Minz_User::name() ?? Minz_User::INTERNAL_USER;
		} elseif (!FreshRSS_user_Controller::checkUsername($username)) {
			return false;
		}
		return touch(USERS_PATH . '/' . $username . '/' . LOG_FILENAME);
	}

	/** Time of the last new content automatically received by the user (e.g., cron job, WebSub) */
	public static function ctime(string $username): int {
		return @filemtime(USERS_PATH . '/' . $username . '/' . LOG_FILENAME) ?: 0;
	}
}
UserQuery.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/UserQuery.php'
View Content
<?php
declare(strict_types=1);

/**
 * Contains the description of a user query
 *
 * It allows to extract the meaningful bits of the query to be manipulated in an
 * easy way.
 */
class FreshRSS_UserQuery {

	private bool $deprecated = false;
	private string $get = '';
	private string $get_name = '';
	private string $get_type = '';
	private string $name = '';
	private string $order = '';
	private FreshRSS_BooleanSearch $search;
	private int $state = 0;
	private string $url = '';
	private string $token = '';
	private bool $shareRss = false;
	private bool $shareOpml = false;
	/** @var array<int,FreshRSS_Category> $categories */
	private array $categories;
	/** @var array<int,FreshRSS_Tag> $labels */
	private array $labels;
	private string $description = '';
	private string $imageUrl = '';

	public static function generateToken(string $salt): string {
		if (!FreshRSS_Context::hasSystemConf()) {
			return '';
		}
		$hash = md5(FreshRSS_Context::systemConf()->salt . $salt . random_bytes(16));
		if (function_exists('gmp_init')) {
			// Shorten the hash if possible by converting from base 16 to base 62
			$hash = gmp_strval(gmp_init($hash, 16), 62);
		}
		return $hash;
	}

	/**
	 * @param array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,
	 * 	shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string} $query
	 * @param array<int,FreshRSS_Category> $categories
	 * @param array<int,FreshRSS_Tag> $labels
	 */
	public function __construct(array $query, array $categories, array $labels) {
		$this->categories = $categories;
		$this->labels = $labels;
		if (isset($query['get'])) {
			$this->parseGet($query['get']);
		} else {
			$this->get_type = 'all';
		}
		if (isset($query['name'])) {
			$this->name = trim($query['name']);
		}
		if (isset($query['order'])) {
			$this->order = $query['order'];
		}
		if (empty($query['url'])) {
			if (!empty($query)) {
				$link = $query;
				unset($link['description']);
				unset($link['imageUrl']);
				unset($link['name']);
				unset($link['shareOpml']);
				unset($link['shareRss']);
				$this->url = Minz_Url::display(['params' => $link]);
			}
		} else {
			$this->url = $query['url'];
		}
		if (!isset($query['search'])) {
			$query['search'] = '';
		}
		if (!empty($query['token'])) {
			$this->token = $query['token'];
		}
		if (isset($query['shareRss'])) {
			$this->shareRss = $query['shareRss'];
		}
		if (isset($query['shareOpml'])) {
			$this->shareOpml = $query['shareOpml'];
		}
		if (isset($query['description'])) {
			$this->description = $query['description'];
		}
		if (isset($query['imageUrl'])) {
			$this->imageUrl = $query['imageUrl'];
		}

		// linked too deeply with the search object, need to use dependency injection
		$this->search = new FreshRSS_BooleanSearch($query['search'], 0, 'AND', false);
		if (!empty($query['state'])) {
			$this->state = intval($query['state']);
		}
	}

	/**
	 * Convert the current object to an array.
	 *
	 * @return array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string,'token'?:string}
	 */
	public function toArray(): array {
		return array_filter([
			'get' => $this->get,
			'name' => $this->name,
			'order' => $this->order,
			'search' => $this->search->getRawInput(),
			'state' => $this->state,
			'url' => $this->url,
			'token' => $this->token,
			'shareRss' => $this->shareRss,
			'shareOpml' => $this->shareOpml,
			'description' => $this->description,
			'imageUrl' => $this->imageUrl,
		]);
	}

	/**
	 * Parse the get parameter in the query string to extract its name and type
	 */
	private function parseGet(string $get): void {
		$this->get = $get;
		if ($this->get === '') {
			$this->get_type = 'all';
		} elseif (preg_match('/(?P<type>[acfistT])(_(?P<id>\d+))?/', $get, $matches)) {
			$id = intval($matches['id'] ?? '0');
			switch ($matches['type']) {
				case 'a':
					$this->get_type = 'all';
					break;
				case 'c':
					$this->get_type = 'category';
					$c = $this->categories[$id] ?? null;
					$this->get_name = $c === null ? '' : $c->name();
					break;
				case 'f':
					$this->get_type = 'feed';
					$f = FreshRSS_Category::findFeed($this->categories, $id);
					$this->get_name = $f === null ? '' : $f->name();
					break;
				case 'i':
					$this->get_type = 'important';
					break;
				case 's':
					$this->get_type = 'favorite';
					break;
				case 't':
					$this->get_type = 'label';
					$l = $this->labels[$id] ?? null;
					$this->get_name = $l === null ? '' : $l->name();
					break;
				case 'T':
					$this->get_type = 'all_labels';
					break;
			}
			if ($this->get_name === '' && in_array($matches['type'], ['c', 'f', 't'], true)) {
				$this->deprecated = true;
			}
		}
	}

	/**
	 * Check if the current user query is deprecated.
	 * It is deprecated if the category or the feed used in the query are
	 * not existing.
	 */
	public function isDeprecated(): bool {
		return $this->deprecated;
	}

	/**
	 * Check if the user query has parameters.
	 */
	public function hasParameters(): bool {
		if ($this->get_type !== 'all') {
			return true;
		}
		if ($this->hasSearch()) {
			return true;
		}
		if (!in_array($this->state, [
				0,
				FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ,
				FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ | FreshRSS_Entry::STATE_FAVORITE | FreshRSS_Entry::STATE_NOT_FAVORITE
			], true)) {
			return true;
		}
		if ($this->order !== '' && $this->order !== FreshRSS_Context::userConf()->sort_order) {
			return true;
		}
		return false;
	}

	/**
	 * Check if there is a search in the search object
	 */
	public function hasSearch(): bool {
		return $this->search->getRawInput() !== '';
	}

	public function getGet(): string {
		return $this->get;
	}

	public function getGetName(): string {
		return $this->get_name;
	}

	public function getGetType(): string {
		return $this->get_type;
	}

	public function getName(): string {
		return $this->name;
	}

	public function getOrder(): string {
		return $this->order ?: FreshRSS_Context::userConf()->sort_order;
	}

	public function getSearch(): FreshRSS_BooleanSearch {
		return $this->search;
	}

	public function getState(): int {
		$state = $this->state;
		if (!($state & FreshRSS_Entry::STATE_READ) && !($state & FreshRSS_Entry::STATE_NOT_READ)) {
			$state |= FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ;
		}
		if (!($state & FreshRSS_Entry::STATE_FAVORITE) && !($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) {
			$state |= FreshRSS_Entry::STATE_FAVORITE | FreshRSS_Entry::STATE_NOT_FAVORITE;
		}
		return $state;
	}

	public function getUrl(): string {
		return $this->url;
	}

	public function getToken(): string {
		return $this->token;
	}

	public function setToken(string $token): void {
		$this->token = $token;
	}

	public function setShareRss(bool $shareRss): void {
		$this->shareRss = $shareRss;
	}

	public function shareRss(): bool {
		return $this->shareRss;
	}

	public function setShareOpml(bool $shareOpml): void {
		$this->shareOpml = $shareOpml;
	}

	public function shareOpml(): bool {
		return $this->shareOpml;
	}

	protected function sharedUrl(bool $xmlEscaped = true): string {
		$currentUser = Minz_User::name() ?? '';
		return Minz_Url::display("/api/query.php?user={$currentUser}&t={$this->token}", $xmlEscaped ? 'html' : '', true);
	}

	public function sharedUrlRss(bool $xmlEscaped = true): string {
		if ($this->shareRss && $this->token !== '') {
			return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=rss';
		}
		return '';
	}

	public function sharedUrlGreader(bool $xmlEscaped = true): string {
		if ($this->shareRss && $this->token !== '') {
			return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=greader';
		}
		return '';
	}

	public function sharedUrlHtml(bool $xmlEscaped = true): string {
		if ($this->shareRss && $this->token !== '') {
			return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=html';
		}
		return '';
	}

	/**
	 * OPML is only safe for some query types, otherwise it risks leaking unwanted feed information.
	 */
	public function safeForOpml(): bool {
		return in_array($this->get_type, ['all', 'category', 'feed'], true);
	}

	public function sharedUrlOpml(bool $xmlEscaped = true): string {
		if ($this->shareOpml && $this->token !== '' && $this->safeForOpml()) {
			return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=opml';
		}
		return '';
	}

	public function getDescription(): string {
		return $this->description;
	}

	public function setDescription(string $description): void {
		$this->description = $description;
	}

	public function getImageUrl(): string {
		return $this->imageUrl;
	}

	public function setImageUrl(string $imageUrl): void {
		$this->imageUrl = $imageUrl;
	}
}
View.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/View.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_View extends Minz_View {

	// Main views
	/** @var callable */
	public $callbackBeforeEntries;
	/** @var callable|null */
	public $callbackBeforeFeeds;
	/** @var callable */
	public $callbackBeforePagination;
	/** @var array<int,FreshRSS_Category> */
	public array $categories;
	public ?FreshRSS_Category $category;
	public ?FreshRSS_Tag $tag;
	public string $current_user;
	/** @var iterable<FreshRSS_Entry> */
	public $entries;
	public FreshRSS_Entry $entry;
	public FreshRSS_Feed $feed;
	/** @var array<int,FreshRSS_Feed> */
	public array $feeds;
	public int $nbUnreadTags;
	/** @var array<int,FreshRSS_Tag> */
	public array $tags;
	/** @var array<int,array{'id':int,'name':string,'id_entry':string,'checked':bool}> */
	public array $tagsForEntry;
	/** @var array<string,array<string>> */
	public array $tagsForEntries;
	public bool $excludeMutedFeeds;

	// Substriptions
	public bool $displaySlider = false;
	public bool $load_ok;
	public bool $onlyFeedsWithError;
	public bool $signalError;

	// Manage users
	/** @var array{'feed_count':int,'article_count':int,'database_size':int,'language':string,'mail_login':string,'enabled':bool,'is_admin':bool,'last_user_activity':string,'is_default':bool} */
	public array $details;
	public bool $disable_aside;
	public bool $show_email_field;
	public string $username;
	/** @var array<array{'language':string,'enabled':bool,'is_admin':bool,'enabled':bool,'article_count':int,'database_size':int,'last_user_activity':string,'mail_login':string,'feed_count':int,'is_default':bool}> */
	public array $users;

	// Updates
	public string $last_update_time;
	/** @var array<string,bool> */
	public array $status_files;
	/** @var array<string,bool> */
	public array $status_php;
	public bool $update_to_apply;
	/** @var array<string,bool> */
	public array $status_database;
	public bool $is_release_channel_stable;

	// Archiving
	public int $nb_total;
	public int $size_total;
	public int $size_user;

	// Display
	/** @var array<string,array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}}> */
	public array $themes;

	// Shortcuts
	/** @var array<int, string> */
	public array $list_keys;

	// User queries
	/** @var array<int,FreshRSS_UserQuery> */
	public array $queries;
	/**  @var FreshRSS_UserQuery|null */
	public ?FreshRSS_UserQuery $query = null;

	// Export / Import
	public string $content;
	/** @var array<string,array<string>> */
	public array $entryIdsTagNames;
	public string $list_title;
	public int $queryId;
	public string $type;

	// Form login
	public int $cookie_days;

	// Registration
	public bool $can_register;
	public string $preferred_language;
	public bool $show_tos_checkbox;
	public string $terms_of_service;
	public string $site_title;
	public string $validation_url;

	// Logs
	public int $currentPage;
	public Minz_Paginator $logsPaginator;
	public int $nbPage;

	// RSS view
	public FreshRSS_UserQuery $userQuery;
	public string $html_url = '';
	public string $rss_title = '';
	public string $rss_url = '';
	public string $rss_base = '';
	public bool $internal_rendering = false;
	public string $description = '';
	public string $image_url = '';

	// Content preview
	public string $fatalError;
	public string $htmlContent;
	public bool $selectorSuccess;

	// Extensions
	/** @var array<array{'name':string,'author':string,'description':string,'version':string,'entrypoint':string,'type':'system'|'user','url':string,'method':string,'directory':string}> */
	public array $available_extensions;
	public ?Minz_Extension $ext_details;
	/** @var array{'system':array<Minz_Extension>,'user':array<Minz_Extension>} */
	public array $extension_list;
	public ?Minz_Extension $extension;
	/** @var array<string,string> */
	public array $extensions_installed;

	// Errors
	public string $code;
	public string $errorMessage;
	/** @var array<string,string> */
	public array $message;
}
ViewJavascript.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/ViewJavascript.php'
View Content
<?php
declare(strict_types=1);

final class FreshRSS_ViewJavascript extends FreshRSS_View {

	/** @var array<int,FreshRSS_Category> */
	public array $categories;
	/** @var array<int,FreshRSS_Feed> */
	public array $feeds;
	/** @var array<int,FreshRSS_Tag> */
	public array $tags;

	public string $nonce;
	public string $salt1;
}
ViewStats.php
wget 'https://lists2.roe3.org/FreshRSS/app/Models/ViewStats.php'
View Content
<?php
declare(strict_types=1);

final class FreshRSS_ViewStats extends FreshRSS_View {

	/** @var array<int,FreshRSS_Category> */
	public array $categories;
	public FreshRSS_Feed $feed;
	/** @var array<int,FreshRSS_Feed> */
	public array $feeds;
	public bool $displaySlider = false;

	public float $average;
	public float $averageDayOfWeek;
	public float $averageHour;
	public float $averageMonth;
	/** @var array<string> */
	public array $days;
	/** @var array<string,array<int,int|string>> */
	public array $entryByCategory;
	/** @var array<int,int> */
	public array $entryCount;
	/** @var array<string,array<int,int|string>> */
	public array $feedByCategory;
	/** @var array<int, string> */
	public array $hours24Labels;
	/** @var array<string,array<int,array<string,int|string>>> */
	public array $idleFeeds;
	/** @var array<int,string> */
	public array $last30DaysLabel;
	/** @var array<int,string> */
	public array $last30DaysLabels;
	/** @var array<string,string> */
	public array $months;
	/** @var array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false */
	public $repartition;
	/** @var array{'main_stream':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false,'all_feeds':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false} */
	public array $repartitions;
	/** @var array<int,int> */
	public array $repartitionDayOfWeek;
	/** @var array<string,int>|array<int,int> */
	public array $repartitionHour;
	/** @var array<int,int> */
	public array $repartitionMonth;
	/** @var array<array{'id':int,'name':string,'category':string,'count':int}> */
	public array $topFeed;
}