Przeglądaj źródła

Fix unwanted expansion of user queries in some cases (#8395)

fix https://github.com/FreshRSS/FreshRSS/issues/8378
Alexandre Alapetite 3 miesięcy temu
rodzic
commit
2527033057

+ 3 - 3
app/Controllers/indexController.php

@@ -94,8 +94,8 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 		};
 		$searchString = $operator . ':' . ($offset < 0 ? '/' : '') . date('Y-m-d', $timestamp + ($offset * 86400)) . ($offset > 0 ? '/' : '');
 		return Minz_Url::display(Minz_Request::modifiedCurrentRequest([
-			'search' => FreshRSS_Context::$search->__toString() === '' ? $searchString :
-				FreshRSS_Context::$search->enforce(new FreshRSS_Search($searchString))->__toString(),
+			'search' => FreshRSS_Context::$search->toString() === '' ? $searchString :
+				FreshRSS_Context::$search->enforce(new FreshRSS_Search($searchString))->toString(),
 			]));
 	}
 
@@ -135,7 +135,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 
 		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
 		$title = FreshRSS_Context::$name;
-		$search = FreshRSS_Context::$search->__toString();
+		$search = FreshRSS_Context::$search->toString(expandUserQueries: false);
 		if ($search !== '') {
 			$title = '“' . htmlspecialchars($search, ENT_COMPAT, 'UTF-8') . '”';
 		}

+ 2 - 2
app/Controllers/subscriptionController.php

@@ -421,7 +421,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 		$actionsSearch = new FreshRSS_BooleanSearch('', operator: 'AND');
 		foreach ($filteractions as $action) {
 			$actionSearch = new FreshRSS_BooleanSearch($action, operator: 'OR');
-			if ($actionSearch->__toString() === '') {
+			if ($actionSearch->toString() === '') {
 				continue;
 			}
 			$actionsSearch->add($actionSearch);
@@ -433,7 +433,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 			'c' => 'index',
 			'a' => 'index',
 			'params' => [
-				'search' => $search->__toString(),
+				'search' => $search->toString(),
 			],
 		], redirect: true);
 	}

+ 56 - 32
app/Models/BooleanSearch.php

@@ -20,7 +20,8 @@ class FreshRSS_BooleanSearch implements \Stringable {
 		string $input,
 		int $level = 0,
 		private readonly string $operator = 'AND',
-		bool $allowUserQueries = true
+		bool $allowUserQueries = true,
+		bool $expandUserQueries = true
 	) {
 		$input = trim($input);
 		if ($input === '') {
@@ -30,8 +31,10 @@ class FreshRSS_BooleanSearch implements \Stringable {
 
 		if ($level === 0) {
 			$input = self::escapeLiterals($input);
-			$input = $this->parseUserQueryNames($input, $allowUserQueries);
-			$input = $this->parseUserQueryIds($input, $allowUserQueries);
+			if ($expandUserQueries || !$allowUserQueries) {
+				$input = $this->parseUserQueryNames($input, $allowUserQueries);
+				$input = $this->parseUserQueryIds($input, $allowUserQueries);
+			}
 			$input = trim($input);
 		}
 
@@ -46,6 +49,8 @@ class FreshRSS_BooleanSearch implements \Stringable {
 		foreach ($this->searches as $key => $search) {
 			$this->searches[$key] = clone $search;
 		}
+		$this->expanded = null;
+		$this->notExpanded = null;
 	}
 
 	/**
@@ -76,13 +81,11 @@ class FreshRSS_BooleanSearch implements \Stringable {
 				}
 				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[] = '(' . self::escapeLiterals($queries[$name]) . ')';
-						} else {
-							$toS[] = '';
-						}
+					$fromS[] = $matches[0][$i];
+					if ($allowUserQueries && !empty($queries[$name])) {
+						$toS[] = '(' . self::escapeLiterals($queries[$name]) . ')';
+					} else {
+						$toS[] = '';
 					}
 				}
 			}
@@ -124,12 +127,9 @@ class FreshRSS_BooleanSearch implements \Stringable {
 							$matchedQueries[] = $queries[$id];
 						}
 					}
-					if (empty($matchedQueries)) {
-						continue;
-					}
 
 					$fromS[] = $matches[0][$i];
-					if ($allowUserQueries) {
+					if ($allowUserQueries && !empty($matchedQueries)) {
 						$escapedQueries = array_map(fn(string $query): string => self::escapeLiterals($query), $matchedQueries);
 						$toS[] = '(' . implode(') OR (', $escapedQueries) . ')';
 					} else {
@@ -447,6 +447,8 @@ class FreshRSS_BooleanSearch implements \Stringable {
 	public function enforce(FreshRSS_Search $search): self {
 		$result = clone $this;
 		$result->raw_input = '';
+		$result->expanded = null;
+		$result->notExpanded = null;
 
 		if (count($result->searches) === 1 && $result->searches[0] instanceof FreshRSS_Search) {
 			$result->searches[0] = $result->searches[0]->enforce($search);
@@ -489,6 +491,8 @@ class FreshRSS_BooleanSearch implements \Stringable {
 	public function remove(FreshRSS_Search $search): self {
 		$result = clone $this;
 		$result->raw_input = '';
+		$result->expanded = null;
+		$result->notExpanded = null;
 
 		if (count($result->searches) === 1 && $result->searches[0] instanceof FreshRSS_Search) {
 			$result->searches[0] = $result->searches[0]->remove($search);
@@ -511,33 +515,53 @@ class FreshRSS_BooleanSearch implements \Stringable {
 		return $result;
 	}
 
+	private ?string $expanded = null;
+
 	#[\Override]
 	public function __toString(): string {
-		$result = '';
-		foreach ($this->searches as $search) {
-			$part = $search->__toString();
-			if ($part === '') {
-				continue;
-			}
-			$operator = $search instanceof FreshRSS_BooleanSearch ? $search->operator : 'OR';
+		if ($this->expanded === null) {
+			$result = '';
+			foreach ($this->searches as $search) {
+				$part = $search->__toString();
+				if ($part === '') {
+					continue;
+				}
+				$operator = $search instanceof FreshRSS_BooleanSearch ? $search->operator : 'OR';
+
+				if ((str_contains($part, ' ') || str_starts_with($part, '-')) && (count($this->searches) > 1 || in_array($operator, ['OR NOT', 'AND NOT'], true))) {
+					$part = '(' . $part . ')';
+				}
 
-			if ((str_contains($part, ' ') || str_starts_with($part, '-')) && (count($this->searches) > 1 || in_array($operator, ['OR NOT', 'AND NOT'], true))) {
-				$part = '(' . $part . ')';
+				$result .= match ($operator) {
+					'OR' => $result === '' ? '' : ' OR ',
+					'OR NOT' => $result === '' ? '-' : ' OR -',
+					'AND NOT' => $result === '' ? '-' : ' -',
+					'AND' => $result === '' ? '' : ' ',
+					default => throw new InvalidArgumentException('Invalid operator: ' . $operator),
+				} . $part;
 			}
+			$this->expanded = trim($result);
+		}
+		return $this->expanded;
+	}
 
-			$result .= match ($operator) {
-				'OR' => $result === '' ? '' : ' OR ',
-				'OR NOT' => $result === '' ? '-' : ' OR -',
-				'AND NOT' => $result === '' ? '-' : ' -',
-				'AND' => $result === '' ? '' : ' ',
-				default => throw new InvalidArgumentException('Invalid operator: ' . $operator),
-			} . $part;
+	private ?string $notExpanded = null;
+
+	/**
+	 * @param bool $expandUserQueries Whether to expand user queries (saved searches) or not
+	 */
+	public function toString(bool $expandUserQueries = true): string {
+		if ($expandUserQueries) {
+			return $this->__toString();
 		}
-		return trim($result);
+		if ($this->notExpanded === null) {
+			$this->notExpanded = (new FreshRSS_BooleanSearch($this->raw_input, expandUserQueries: false))->__toString();
+		}
+		return $this->notExpanded;
 	}
 
 	/** @return string Plain text search query. Must be XML-encoded or URL-encoded depending on the situation */
-	#[Deprecated('Use __tostring() instead')]
+	#[Deprecated('Use __toString(expanded: false) instead')]
 	public function getRawInput(): string {
 		return $this->raw_input;
 	}

+ 1 - 1
app/Models/FilterAction.php

@@ -33,7 +33,7 @@ class FreshRSS_FilterAction {
 	public function toJSON(): array {
 		if (is_array($this->actions) && $this->booleanSearch != null) {
 			return [
-				'search' => $this->booleanSearch->__toString(),
+				'search' => $this->booleanSearch->toString(expandUserQueries: false),
 				'actions' => $this->actions,
 			];
 		}

+ 2 - 2
app/Models/FilterActionsTrait.php

@@ -71,7 +71,7 @@ trait FreshRSS_FilterActionsTrait {
 		//Check existing filters
 		for ($i = count($filterActions) - 1; $i >= 0; $i--) {
 			$filterAction = $filterActions[$i];
-			if ($filterAction === null || !is_array($filterAction->actions()) || $filterAction->booleanSearch()->__toString() === '') {
+			if ($filterAction === null || !is_array($filterAction->actions()) || $filterAction->booleanSearch()->toString() === '') {
 				array_splice($filterActions, $i, 1);
 				continue;
 			}
@@ -85,7 +85,7 @@ trait FreshRSS_FilterActionsTrait {
 			//Update existing filter with new action
 			for ($k = count($filters) - 1; $k >= 0; $k--) {
 				$filter = $filters[$k];
-				if ($filter === $filterAction->booleanSearch()->__toString()) {
+				if ($filter === $filterAction->booleanSearch()->toString()) {
 					$actions[] = $action;
 					array_splice($filters, $k, 1);
 				}

+ 1 - 1
app/Models/Search.php

@@ -447,7 +447,7 @@ class FreshRSS_Search implements \Stringable {
 		return trim($result);
 	}
 
-	#[Deprecated('Use __tostring() instead')]
+	#[Deprecated('Use __toString() instead')]
 	public function getRawInput(): string {
 		return $this->raw_input;
 	}

+ 2 - 2
app/Models/UserQuery.php

@@ -123,7 +123,7 @@ class FreshRSS_UserQuery {
 			'get' => $this->get,
 			'name' => $this->name,
 			'order' => $this->order,
-			'search' => $this->search->__toString(),
+			'search' => $this->search->toString(expandUserQueries: false),
 			'state' => $this->state,
 			'url' => $this->url,
 			'token' => $this->token,
@@ -221,7 +221,7 @@ class FreshRSS_UserQuery {
 	 * Check if there is a search in the search object
 	 */
 	public function hasSearch(): bool {
-		return $this->search->__toString() !== '';
+		return $this->search->toString() !== '';
 	}
 
 	public function getGet(): string {

+ 1 - 1
app/layout/header.phtml

@@ -40,7 +40,7 @@
 			<?php } ?>
 			<div class="stick">
 				<input type="search" name="search" id="search"
-					value="<?= htmlspecialchars(FreshRSS_Context::$search->__toString(), ENT_COMPAT, 'UTF-8') ?>"
+					value="<?= htmlspecialchars(FreshRSS_Context::$search->toString(), ENT_COMPAT, 'UTF-8') ?>"
 					placeholder="<?= _t('gen.menu.search') ?>" />
 				<button class="btn" type="submit"><?= _i('search') ?></button>
 			</div>

+ 3 - 3
app/layout/nav_menu.phtml

@@ -39,7 +39,7 @@
 			<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
 			<div id="dropdown-search" class="dropdown-target"></div>
 
-			<a id="toggle-search" class="dropdown-toggle btn<?= FreshRSS_Context::$search->__toString() !== '' ? ' active' : ''; ?>" title="<?= _t('gen.menu.search') ?>"
+			<a id="toggle-search" class="dropdown-toggle btn<?= FreshRSS_Context::$search->toString() !== '' ? ' active' : ''; ?>" title="<?= _t('gen.menu.search') ?>"
 				href="#dropdown-search"><?= _i('search') ?></a>
 			<ul class="dropdown-menu">
 				<li class="item">
@@ -56,7 +56,7 @@
 							<?php } ?>
 							<div class="stick search">
 								<input type="search" name="search"
-									value="<?= htmlspecialchars(FreshRSS_Context::$search->__toString(), ENT_COMPAT, 'UTF-8') ?>"
+									value="<?= htmlspecialchars(FreshRSS_Context::$search->toString(), ENT_COMPAT, 'UTF-8') ?>"
 									placeholder="<?= _t('gen.menu.search') ?>" title="<?= _t('gen.menu.search') ?>" /><button class="btn" type="submit" title="<?= _t('index.menu.search_short') ?>"><?= _i('search') ?></button>
 							</div>
 							<p class="help"><?= _i('help') ?> <a href="<?= _url('search', 'index') ?>"><?= _t('gen.menu.advanced_search') ?></a></p>
@@ -121,7 +121,7 @@
 				'get' => $get,
 				'nextGet' => FreshRSS_Context::$next_get,
 				'idMax' => FreshRSS_Context::$id_max,
-				'search' => FreshRSS_Context::$search->__toString(),
+				'search' => FreshRSS_Context::$search->toString(),
 				'state' => FreshRSS_Context::$state,
 				'sort' => FreshRSS_Context::$sort,
 				'order' => FreshRSS_Context::$order,

+ 2 - 2
app/views/configure/queries.phtml

@@ -24,7 +24,7 @@
 							<input type="hidden" id="queries_<?= $key ?>_shareRss" name="queries[<?= $key ?>][shareRss]" value="<?= $query->shareRss() ? '1' : '0' ?>"/>
 							<input type="hidden" id="queries_<?= $key ?>_shareOpml" name="queries[<?= $key ?>][shareOpml]" value="<?= $query->shareOpml() ? '1' : '0' ?>"/>
 							<input type="hidden" id="queries_<?= $key ?>_url" name="queries[<?= $key ?>][url]" value="<?= $query->getUrl() ?>"/>
-							<input type="hidden" id="queries_<?= $key ?>_search" name="queries[<?= $key ?>][search]" value="<?= urlencode($query->getSearch()->__toString()) ?>"/>
+							<input type="hidden" id="queries_<?= $key ?>_search" name="queries[<?= $key ?>][search]" value="<?= urlencode($query->getSearch()->toString(expandUserQueries: false)) ?>"/>
 							<input type="hidden" id="queries_<?= $key ?>_state" name="queries[<?= $key ?>][state]" value="<?= $query->getState() ?>"/>
 							<input type="hidden" id="queries_<?= $key ?>_order" name="queries[<?= $key ?>][order]" value="<?= $query->getOrder() ?>"/>
 							<input type="hidden" id="queries_<?= $key ?>_get" name="queries[<?= $key ?>][get]" value="<?= $query->getGet() ?>"/>
@@ -44,7 +44,7 @@
 						<?php } else { ?>
 							<ul class="box-content scrollbar-thin">
 							<?php if ($query->hasSearch()) { ?>
-							<li class="item"><?= _t('conf.query.search', htmlspecialchars($query->getSearch()->__toString(), ENT_NOQUOTES, 'UTF-8')) ?></li>
+							<li class="item"><?= _t('conf.query.search', htmlspecialchars($query->getSearch()->toString(expandUserQueries: false), ENT_NOQUOTES, 'UTF-8')) ?></li>
 							<?php } ?>
 
 							<?php if ($query->getState()) { ?>

+ 2 - 2
app/views/configure/reading.phtml

@@ -349,7 +349,7 @@
 				<div class="group-controls">
 					<textarea name="filteractions_read" id="filteractions_read" class="w100"><?php
 						foreach (FreshRSS_Context::userConf()->filtersAction('read') as $filterRead) {
-							echo htmlspecialchars($filterRead->__toString(), ENT_NOQUOTES, 'UTF-8'), PHP_EOL;
+							echo htmlspecialchars($filterRead->toString(expandUserQueries: false), ENT_NOQUOTES, 'UTF-8'), PHP_EOL;
 						}
 					?></textarea>
 					<p class="help"><?= _i('help') ?> <?= _t('sub.feed.filteractions.help') ?></p>
@@ -366,7 +366,7 @@
 				<div class="group-controls">
 					<textarea name="filteractions_star" id="filteractions_star" class="w100"><?php
 						foreach (FreshRSS_Context::userConf()->filtersAction('star') as $filterStar) {
-							echo htmlspecialchars($filterStar->__toString(), ENT_NOQUOTES, 'UTF-8'), PHP_EOL;
+							echo htmlspecialchars($filterStar->toString(expandUserQueries: false), ENT_NOQUOTES, 'UTF-8'), PHP_EOL;
 						}
 					?></textarea>
 					<p class="help"><?= _i('help') ?> <?= _t('sub.feed.filteractions.help') ?></p>

+ 1 - 1
app/views/helpers/category/update.phtml

@@ -96,7 +96,7 @@
 				<div class="group-controls">
 					<textarea name="filteractions_read" id="filteractions_read" class="w100"><?php
 						foreach ($this->category->filtersAction('read') as $filterRead) {
-							echo htmlspecialchars($filterRead->__toString(), ENT_NOQUOTES, 'UTF-8'), PHP_EOL;
+							echo htmlspecialchars($filterRead->toString(expandUserQueries: false), ENT_NOQUOTES, 'UTF-8'), PHP_EOL;
 						}
 					?></textarea>
 					<p class="help"><?= _i('help') ?> <?= _t('sub.feed.filteractions.help') ?></p>

+ 2 - 1
app/views/helpers/configure/query.phtml

@@ -92,7 +92,8 @@
 			<div class="form-group">
 				<label class="group-name" for=""><?= _t('conf.query.filter.search') ?></label>
 				<div class="group-controls">
-					<input type="text" class="w100" id="query_search" name="query[search]" value="<?= htmlspecialchars($this->query->getSearch()->__toString(), ENT_COMPAT, 'UTF-8') ?>"/>
+					<input type="text" class="w100" id="query_search" name="query[search]" value="<?=
+						htmlspecialchars($this->query->getSearch()->toString(expandUserQueries: false), ENT_COMPAT, 'UTF-8') ?>"/>
 					<p class="help"><?= _i('help') ?> <?= _t('gen.menu.search_help') ?></a></p>
 				</div>
 			</div>

+ 1 - 1
app/views/helpers/export/opml.phtml

@@ -89,7 +89,7 @@ function feedsToOutlines(array $feeds, bool $excludeMutedFeeds = false): array {
 		if (!empty($feed->filtersAction('read'))) {
 			$filters = '';
 			foreach ($feed->filtersAction('read') as $filterRead) {
-				$filters .= $filterRead->__toString() . "\n";
+				$filters .= $filterRead->toString(expandUserQueries: false) . "\n";
 			}
 			$filters = trim($filters);
 			$outline['frss:filtersActionRead'] = $filters;

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

@@ -304,7 +304,7 @@
 					<textarea name="filteractions_read" id="filteractions_read" class="w100"
 						placeholder="<?= _t('gen.short.blank_to_disable') ?>"><?php
 						foreach ($this->feed->filtersAction('read') as $filterRead) {
-							echo htmlspecialchars($filterRead->__toString(), ENT_NOQUOTES, 'UTF-8'), PHP_EOL;
+							echo htmlspecialchars($filterRead->toString(expandUserQueries: false), ENT_NOQUOTES, 'UTF-8'), PHP_EOL;
 						}
 					?></textarea>
 					<p class="help"><?= _i('help') ?> <?= _t('sub.feed.filteractions.help') ?></p>

+ 1 - 1
app/views/helpers/stream-footer.phtml

@@ -18,7 +18,7 @@
 			'get' => FreshRSS_Context::currentGet(),
 			'nextGet' => FreshRSS_Context::$next_get,
 			'idMax' => FreshRSS_Context::$id_max,
-			'search' => FreshRSS_Context::$search->__toString(),
+			'search' => FreshRSS_Context::$search->toString(),
 			'state' => FreshRSS_Context::$state,
 			'sort' => FreshRSS_Context::$sort,
 			'order' => FreshRSS_Context::$order,

+ 1 - 1
app/views/tag/update.phtml

@@ -45,7 +45,7 @@
 				<div class="group-controls">
 					<textarea name="filteractions_label" id="filteractions_label" class="w100"><?php
 						foreach ($this->tag->filtersAction('label') as $filterRead) {
-							echo htmlspecialchars($filterRead->__toString(), ENT_NOQUOTES, 'UTF-8') . PHP_EOL;
+							echo htmlspecialchars($filterRead->toString(expandUserQueries: false), ENT_NOQUOTES, 'UTF-8') . PHP_EOL;
 						}
 					?></textarea>
 					<p class="help"><?= _i('help') ?> <?= _t('sub.feed.filteractions.help') ?></p>

+ 5 - 5
p/api/query.php

@@ -92,14 +92,14 @@ foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
 		}
 		Minz_Request::_param('state', (string)$query->getState());
 
-		$search = $query->getSearch()->__toString();
+		$search = $query->getSearch()->toString();
 		// Note: we disallow references to user queries in public user search to avoid sniffing internal user queries
 		$userSearch = new FreshRSS_BooleanSearch(Minz_Request::paramString('search', plaintext: true), 0, 'AND', allowUserQueries: false);
-		if ($userSearch->__toString() !== '') {
+		if ($userSearch->toString() !== '') {
 			if ($search === '') {
-				$search = $userSearch->__toString();
+				$search = $userSearch->toString();
 			} else {
-				$search .= ' (' . $userSearch->__toString() . ')';
+				$search .= ' (' . $userSearch->toString() . ')';
 			}
 		}
 		Minz_Request::_param('search', $search);
@@ -117,7 +117,7 @@ $view = new FreshRSS_View();
 
 try {
 	FreshRSS_Context::updateUsingRequest(false);
-	Minz_Request::_param('search', $userSearch->__toString());	// Restore user search
+	Minz_Request::_param('search', $userSearch->toString());	// Restore user search
 	$view->entries = FreshRSS_index_Controller::listEntriesByContext();
 } catch (Minz_Exception) {
 	Minz_Error::error(400, 'Bad user query!');

+ 97 - 11
tests/app/Models/SearchTest.php

@@ -243,11 +243,11 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 	}
 
 	/**
-	 * @param list<array{search:string}> $queries
+	 * @param list<array{search:string,name?:string}> $queries
 	 * @param array{0:string,1:list<string|int>} $expectedResult
 	 */
-	#[DataProvider('provideSavedQueryIdExpansion')]
-	public static function test__construct_whenInputContainsSavedQueryIds_expandsSavedSearches(array $queries, string $input, array $expectedResult): void {
+	#[DataProvider('provideSavedQueriesExpansion')]
+	public static function test__construct_whenInputContainsSavedQueries_expandsSavedSearches(array $queries, string $input, array $expectedResult): void {
 		$previousUserConf = FreshRSS_Context::hasUserConf() ? FreshRSS_Context::userConf() : null;
 		$newUserConf = $previousUserConf instanceof FreshRSS_UserConfiguration ? clone $previousUserConf : clone FreshRSS_UserConfiguration::default();
 		$newUserConf->queries = $queries;
@@ -266,14 +266,36 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 	/**
 	 * @return array<string,array{0:list<array{search:string}>,1:string,2:array{0:string,1:list<string|int>}}>
 	 */
-	public static function provideSavedQueryIdExpansion(): array {
+	public static function provideSavedQueriesExpansion(): array {
 		return [
-			'expanded single group' => [
+			'not found ID' => [
 				[
 					['search' => 'author:Alice'],
 					['search' => 'intitle:World'],
 				],
-				'S:0,1',
+				'S:3',
+				[
+					'',
+					[],
+				],
+			],
+			'not found name' => [
+				[
+					['search' => 'author:Alice', 'name' => 'First'],
+					['search' => 'intitle:World', 'name' => 'Second'],
+				],
+				'search:Third',
+				[
+					'',
+					[],
+				],
+			],
+			'expanded single group name' => [
+				[
+					['search' => 'author:Alice', 'name' => 'First'],
+					['search' => 'intitle:World', 'name' => 'Second'],
+				],
+				'search:First OR search:Second',
 				[
 					'((e.author LIKE ? )) OR ((e.title LIKE ? ))',
 					['%Alice%', '%World%'],
@@ -286,7 +308,7 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 					['search' => 'inurl:Example'],
 					['search' => 'author:Bob'],
 				],
-				'S:0,1 OR S:2,3',
+				'S:0,1 OR S:2,3,5',
 				[
 					'((e.author LIKE ? )) OR ((e.title LIKE ? )) OR ((e.link LIKE ? )) OR ((e.author LIKE ? ))',
 					['%Alice%', '%World%', '%Example%', '%Bob%'],
@@ -970,9 +992,9 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 	}
 
 	#[DataProvider('provideBooleanSearchToString')]
-	public static function testBooleanSearch__toString(string $input, string $expected): void {
+	public static function testBooleanSearchToString(string $input, string $expected): void {
 		$search = new FreshRSS_BooleanSearch($input);
-		self::assertSame($expected, $search->__toString());
+		self::assertSame($expected, $search->toString());
 	}
 
 	/**
@@ -1023,6 +1045,70 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 		];
 	}
 
+	/**
+	 * @param list<array{search:string,name?:string}> $queries
+	 */
+	#[DataProvider('provideBooleanSearchToStringExpansion')]
+	public static function testBooleanSearchToStringExpansion(array $queries, string $input,
+		string $expectedNotExpanded, string $expectedExpanded): void {
+		$previousUserConf = FreshRSS_Context::hasUserConf() ? FreshRSS_Context::userConf() : null;
+		$newUserConf = $previousUserConf instanceof FreshRSS_UserConfiguration ? clone $previousUserConf : clone FreshRSS_UserConfiguration::default();
+		$newUserConf->queries = $queries;
+		FreshRSS_Context::setUserConf($newUserConf);
+
+		try {
+			$booleanSearch = new FreshRSS_BooleanSearch($input);
+			self::assertSame($expectedNotExpanded, $booleanSearch->toString(expandUserQueries: false));
+			self::assertSame($expectedExpanded, $booleanSearch->toString());
+		} finally {
+			FreshRSS_Context::setUserConf($previousUserConf);
+		}
+	}
+
+	/**
+	 * @return array<string,array{0:list<array{search:string,name?:string}>,1:string,2:string,3:string}>
+	 */
+	public static function provideBooleanSearchToStringExpansion(): array {
+		return [
+			'Not found ID' => [
+				[
+					['search' => 'author:Alice'],
+					['search' => 'intitle:World'],
+				],
+				'S:3 S:4,5 ',
+				'S:3 S:4,5',
+				'',
+			],
+			'Not found name' => [
+				[
+					['search' => 'author:Alice', 'name' => 'First'],
+					['search' => 'intitle:World', 'name' => 'Second'],
+				],
+				'search:Third ',
+				'search:Third',
+				'',
+			],
+			'Found IDs' => [
+				[
+					['search' => 'author:Alice', 'name' => 'First'],
+					['search' => 'intitle:World', 'name' => 'Second'],
+				],
+				'S:0,1 ',
+				'S:0,1',
+				'author:Alice OR intitle:World',
+			],
+			'Found names' => [
+				[
+					['search' => 'author:Alice', 'name' => 'First'],
+					['search' => 'intitle:World', 'name' => 'Second'],
+				],
+				'search:First search:Second ',
+				'search:First search:Second',
+				'author:Alice intitle:World',
+			],
+		];
+	}
+
 	#[DataProvider('provideHasSameOperators')]
 	public function testHasSameOperators(string $input1, string $input2, bool $expected): void {
 		$search1 = new FreshRSS_Search($input1);
@@ -1047,7 +1133,7 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 		$searchToEnforce = new FreshRSS_Search($enforceInput);
 		$newBooleanSearch = $booleanSearch->enforce($searchToEnforce);
 		self::assertNotSame($booleanSearch, $newBooleanSearch);
-		self::assertSame($expectedOutput, $newBooleanSearch->__toString());
+		self::assertSame($expectedOutput, $newBooleanSearch->toString());
 	}
 
 	/**
@@ -1081,7 +1167,7 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 		$searchToRemove = new FreshRSS_Search($removeInput);
 		$newBooleanSearch = $booleanSearch->remove($searchToRemove);
 		self::assertNotSame($booleanSearch, $newBooleanSearch);
-		self::assertSame($expectedOutput, $newBooleanSearch->__toString());
+		self::assertSame($expectedOutput, $newBooleanSearch->toString());
 	}
 
 	/**