BooleanSearch.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * Contains Boolean search from the search form.
  5. */
  6. class FreshRSS_BooleanSearch implements \Stringable {
  7. private string $raw_input = '';
  8. /** @var list<FreshRSS_BooleanSearch|FreshRSS_Search> */
  9. private array $searches = [];
  10. /**
  11. * @param string $input
  12. * @param int $level
  13. * @param 'AND'|'OR'|'AND NOT'|'OR NOT' $operator
  14. * @param bool $allowUserQueries
  15. * @throws Minz_BadRequestException if the search is too long or if the parentheses are nested too deeply
  16. */
  17. public function __construct(
  18. string $input,
  19. int $level = 0,
  20. private readonly string $operator = 'AND',
  21. bool $allowUserQueries = true,
  22. bool $expandUserQueries = true
  23. ) {
  24. $input = trim($input);
  25. $input = ltrim($input, ' )');
  26. $input = rtrim($input, ' (\\');
  27. if ($input === '') {
  28. return;
  29. }
  30. $this->raw_input = $input;
  31. if ($level === 0) {
  32. if (strlen($input) > FreshRSS_Context::systemConf()->limits['max_search_length']) {
  33. throw new Minz_BadRequestException('Search is too long!');
  34. }
  35. $input = self::escapeLiterals($input);
  36. if ($expandUserQueries || !$allowUserQueries) {
  37. $input = $this->parseUserQueryNames($input, $allowUserQueries);
  38. $input = $this->parseUserQueryIds($input, $allowUserQueries);
  39. }
  40. $input = trim($input);
  41. }
  42. $input = self::consistentOrParentheses($input);
  43. // Either parse everything as a series of BooleanSearch’s combined by implicit AND
  44. // or parse everything as a series of Search’s combined by explicit OR
  45. $this->parseParentheses($input, $level) || $this->parseOrSegments($input);
  46. }
  47. public function __clone() {
  48. foreach ($this->searches as $key => $search) {
  49. $this->searches[$key] = clone $search;
  50. }
  51. $this->expanded = null;
  52. $this->notExpanded = null;
  53. }
  54. /**
  55. * Parse the user queries (saved searches) by name and expand them in the input string.
  56. */
  57. private function parseUserQueryNames(string $input, bool $allowUserQueries = true): string {
  58. $all_matches = [];
  59. if (preg_match_all('/\bsearch:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matchesFound)) {
  60. $all_matches[] = $matchesFound;
  61. }
  62. if (preg_match_all('/\bsearch:(?P<search>[^\s"\']*)/', $input, $matchesFound)) {
  63. $all_matches[] = $matchesFound;
  64. }
  65. if (!empty($all_matches)) {
  66. $queries = [];
  67. foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
  68. if (($raw_query['name'] ?? '') !== '' && ($raw_query['search'] ?? '') !== '') {
  69. $queries[$raw_query['name']] = trim($raw_query['search']);
  70. }
  71. }
  72. $fromS = [];
  73. $toS = [];
  74. foreach ($all_matches as $matches) {
  75. if (empty($matches['search'])) {
  76. continue;
  77. }
  78. for ($i = count($matches['search']) - 1; $i >= 0; $i--) {
  79. $name = trim($matches['search'][$i]);
  80. $name = self::unescapeLiterals($name);
  81. $fromS[] = $matches[0][$i];
  82. if ($allowUserQueries && !empty($queries[$name])) {
  83. $toS[] = '(' . self::escapeLiterals($queries[$name]) . ')';
  84. } else {
  85. $toS[] = '';
  86. }
  87. }
  88. }
  89. $input = str_replace($fromS, $toS, $input);
  90. }
  91. return $input;
  92. }
  93. /**
  94. * Parse the user queries (saved searches) by ID and expand them in the input string.
  95. */
  96. private function parseUserQueryIds(string $input, bool $allowUserQueries = true): string {
  97. $all_matches = [];
  98. if (preg_match_all('/\bS:(?P<search>[0-9,]+)/', $input, $matchesFound)) {
  99. $all_matches[] = $matchesFound;
  100. }
  101. if (!empty($all_matches)) {
  102. $queries = [];
  103. foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
  104. $queries[] = trim($raw_query['search'] ?? '');
  105. }
  106. $fromS = [];
  107. $toS = [];
  108. foreach ($all_matches as $matches) {
  109. if (empty($matches['search'])) { // @phpstan-ignore empty.offset (for additional safety)
  110. continue;
  111. }
  112. for ($i = count($matches['search']) - 1; $i >= 0; $i--) {
  113. $ids = explode(',', $matches['search'][$i]);
  114. $ids = array_map('intval', $ids);
  115. $matchedQueries = [];
  116. foreach ($ids as $id) {
  117. if (!empty($queries[$id])) {
  118. $matchedQueries[] = $queries[$id];
  119. }
  120. }
  121. $fromS[] = $matches[0][$i];
  122. if ($allowUserQueries && !empty($matchedQueries)) {
  123. $escapedQueries = array_map(fn(string $query): string => self::escapeLiterals($query), $matchedQueries);
  124. $toS[] = '((' . implode(') OR (', $escapedQueries) . '))';
  125. } else {
  126. $toS[] = '';
  127. }
  128. }
  129. }
  130. $input = str_replace($fromS, $toS, $input);
  131. }
  132. return $input;
  133. }
  134. /**
  135. * Temporarily escape parentheses and 'OR' used in regex expressions or inside "quoted strings".
  136. */
  137. public static function escapeLiterals(string $input): string {
  138. return preg_replace_callback('%(?<=[\\s(:#!-]|^)(?<![\\\\])(?P<delim>[\'"/]).+?(?<!\\\\)(?P=delim)[im]*%',
  139. function (array $matches): string {
  140. $match = $matches[0];
  141. $match = str_replace(['(', ')'], ['\\u0028', '\\u0029'], $match);
  142. $match = preg_replace_callback('/\bOR\b/i', fn(array $ms): string =>
  143. str_replace(['O', 'o', 'R', 'r'], ['\\u004f', '\\u006f', '\\u0052', '\\u0072'], $ms[0]),
  144. $match
  145. ) ?? '';
  146. return $match;
  147. },
  148. $input
  149. ) ?? '';
  150. }
  151. public static function unescapeLiterals(string $input): string {
  152. return str_replace(
  153. ['\\u0028', '\\u0029', '\\u004f', '\\u006f', '\\u0052', '\\u0072'],
  154. ['(', ')', 'O', 'o', 'R', 'r'],
  155. $input
  156. );
  157. }
  158. /**
  159. * Example: 'ab cd OR ef OR "gh ij"' becomes '(ab cd) OR (ef) OR ("gh ij")'
  160. */
  161. public static function addOrParentheses(string $input): string {
  162. $input = trim($input);
  163. if ($input === '') {
  164. return '';
  165. }
  166. $splits = preg_split('/\b(OR)\b/i', $input, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
  167. $ns = count($splits);
  168. if ($ns <= 1) {
  169. return $input;
  170. }
  171. $result = '';
  172. $segment = '';
  173. for ($i = 0; $i < $ns; $i++) {
  174. $segment .= $splits[$i];
  175. if (trim($segment) === '') {
  176. $segment = '';
  177. } elseif (strcasecmp($segment, 'OR') === 0) {
  178. $result .= $segment . ' ';
  179. $segment = '';
  180. } else {
  181. $quotes = substr_count($segment, '"') + substr_count($segment, '&quot;');
  182. if ($quotes % 2 === 0) {
  183. $segment = trim($segment);
  184. if (in_array($segment, ['!', '-'], true)) {
  185. $result .= $segment;
  186. } else {
  187. $result .= '(' . $segment . ') ';
  188. }
  189. $segment = '';
  190. }
  191. }
  192. }
  193. $segment = trim($segment);
  194. if (in_array($segment, ['!', '-'], true)) {
  195. $result .= $segment;
  196. } elseif ($segment !== '') {
  197. $result .= '(' . $segment . ')';
  198. }
  199. return trim($result);
  200. }
  201. /**
  202. * If the query contains a mix of `OR` expressions with and without parentheses,
  203. * then add parentheses to make the query consistent.
  204. * Example: '(ab (cd OR ef)) OR gh OR ij OR (kl)' becomes '(ab ((cd) OR (ef))) OR (gh) OR (ij) OR (kl)'
  205. *
  206. * @throws Minz_BadRequestException if the search is too long or if the parentheses are nested too deeply
  207. */
  208. public static function consistentOrParentheses(string $input): string {
  209. if (strlen($input) > FreshRSS_Context::systemConf()->limits['max_search_length']) {
  210. throw new Minz_BadRequestException('Search is too long!');
  211. }
  212. if (!preg_match('/(?<!\\\\)\\(/', $input)) {
  213. // No unescaped parentheses in the input
  214. return trim($input);
  215. }
  216. $parenthesesCount = 0;
  217. $result = '';
  218. $segment = '';
  219. $length = strlen($input);
  220. for ($i = 0; $i < $length; $i++) {
  221. $c = $input[$i];
  222. $backslashed = $i >= 1 ? $input[$i - 1] === '\\' : false;
  223. if (!$backslashed) {
  224. if ($c === '(') {
  225. if ($parenthesesCount === 0) {
  226. if ($segment !== '') {
  227. $result = rtrim($result) . ' ' . self::addOrParentheses($segment);
  228. $negation = preg_match('/[!-]$/', $result);
  229. if (!$negation) {
  230. $result .= ' ';
  231. }
  232. $segment = '';
  233. }
  234. $c = '';
  235. }
  236. if ($parenthesesCount >= FreshRSS_Context::systemConf()->limits['max_search_parentheses_depth']) {
  237. throw new Minz_BadRequestException('Search has too deeply nested parentheses!');
  238. }
  239. $parenthesesCount++;
  240. } elseif ($c === ')') {
  241. $parenthesesCount--;
  242. if ($parenthesesCount === 0) {
  243. $segment = self::consistentOrParentheses($segment);
  244. if ($segment !== '') {
  245. $result .= '(' . $segment . ')';
  246. $segment = '';
  247. }
  248. $c = '';
  249. }
  250. }
  251. }
  252. $segment .= $c;
  253. }
  254. if (trim($segment) !== '') {
  255. $result = rtrim($result);
  256. $negation = preg_match('/[!-]$/', $segment);
  257. if (!$negation) {
  258. $result .= ' ';
  259. }
  260. $result .= self::addOrParentheses($segment);
  261. }
  262. return trim($result);
  263. }
  264. /** @return bool True if some parenthesis logic took over, false otherwise */
  265. private function parseParentheses(string $input, int $level): bool {
  266. $input = trim($input);
  267. $length = strlen($input);
  268. $i = 0;
  269. $before = '';
  270. $hasParenthesis = false;
  271. $nextOperator = 'AND';
  272. while ($i < $length) {
  273. $c = $input[$i];
  274. $backslashed = $i >= 1 ? $input[$i - 1] === '\\' : false;
  275. if ($c === '(' && !$backslashed) {
  276. $hasParenthesis = true;
  277. $before = trim($before);
  278. if (preg_match('/[!-]$/', $before)) {
  279. // Trim trailing negation
  280. $before = rtrim($before, ' !-');
  281. $isOr = preg_match('/\bOR$/i', $before);
  282. if ($isOr) {
  283. // Trim trailing OR
  284. $before = substr($before, 0, -2);
  285. }
  286. // The text prior to the negation is a BooleanSearch
  287. $searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
  288. if (count($searchBefore->searches()) > 0) {
  289. $this->searches[] = $searchBefore;
  290. }
  291. $before = '';
  292. // The next BooleanSearch will have to be combined with AND NOT or OR NOT instead of default AND
  293. $nextOperator = $isOr ? 'OR NOT' : 'AND NOT';
  294. } elseif (preg_match('/\bOR$/i', $before)) {
  295. // Trim trailing OR
  296. $before = substr($before, 0, -2);
  297. // The text prior to the OR is a BooleanSearch
  298. $searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
  299. if (count($searchBefore->searches()) > 0) {
  300. $this->searches[] = $searchBefore;
  301. }
  302. $before = '';
  303. // The next BooleanSearch will have to be combined with OR instead of default AND
  304. $nextOperator = 'OR';
  305. } elseif ($before !== '') {
  306. // The text prior to the opening parenthesis is a BooleanSearch
  307. $searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
  308. if (count($searchBefore->searches()) > 0) {
  309. $this->searches[] = $searchBefore;
  310. }
  311. $before = '';
  312. }
  313. // Search the matching closing parenthesis
  314. $parentheses = 1;
  315. $sub = '';
  316. $i++;
  317. while ($i < $length) {
  318. $c = $input[$i];
  319. $backslashed = $input[$i - 1] === '\\';
  320. if ($c === '(' && !$backslashed) {
  321. // One nested level deeper
  322. $parentheses++;
  323. $sub .= $c;
  324. } elseif ($c === ')' && !$backslashed) {
  325. $parentheses--;
  326. if ($parentheses === 0) {
  327. // Found the matching closing parenthesis
  328. $searchSub = new FreshRSS_BooleanSearch($sub, $level + 1, $nextOperator);
  329. $nextOperator = 'AND';
  330. if (count($searchSub->searches()) > 0) {
  331. $this->searches[] = $searchSub;
  332. }
  333. $sub = '';
  334. break;
  335. } else {
  336. $sub .= $c;
  337. }
  338. } else {
  339. $sub .= $c;
  340. }
  341. $i++;
  342. }
  343. // $sub = trim($sub);
  344. // if ($sub !== '') {
  345. // // TODO: Consider throwing an error or warning in case of non-matching parenthesis
  346. // }
  347. // } elseif ($c === ')') {
  348. // // TODO: Consider throwing an error or warning in case of non-matching parenthesis
  349. } else {
  350. $before .= $c;
  351. }
  352. $i++;
  353. }
  354. if ($hasParenthesis) {
  355. $before = trim($before);
  356. if (preg_match('/^OR\b/i', $before)) {
  357. // The next BooleanSearch will have to be combined with OR instead of default AND
  358. $nextOperator = 'OR';
  359. // Trim leading OR
  360. $before = substr($before, 2);
  361. }
  362. // The remaining text after the last parenthesis is a BooleanSearch
  363. $searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
  364. $nextOperator = 'AND';
  365. if (count($searchBefore->searches()) > 0) {
  366. $this->searches[] = $searchBefore;
  367. }
  368. return true;
  369. }
  370. // There was no parenthesis logic to apply
  371. return false;
  372. }
  373. private function parseOrSegments(string $input): void {
  374. $input = trim($input);
  375. if ($input === '') {
  376. return;
  377. }
  378. $splits = preg_split('/\b(OR)\b/i', $input, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
  379. $segment = '';
  380. $ns = count($splits);
  381. for ($i = 0; $i < $ns; $i++) {
  382. $segment = $segment . $splits[$i];
  383. if (trim($segment) === '' || strcasecmp($segment, 'OR') === 0) {
  384. $segment = '';
  385. } else {
  386. $quotes = substr_count($segment, '"') + substr_count($segment, '&quot;');
  387. if ($quotes % 2 === 0) {
  388. $segment = trim($segment);
  389. $this->searches[] = new FreshRSS_Search($segment);
  390. $segment = '';
  391. }
  392. }
  393. }
  394. $segment = trim($segment);
  395. if ($segment !== '') {
  396. $this->searches[] = new FreshRSS_Search($segment);
  397. }
  398. }
  399. /**
  400. * Either a list of FreshRSS_BooleanSearch combined by implicit AND
  401. * or a series of FreshRSS_Search combined by explicit OR
  402. * @return list<FreshRSS_BooleanSearch|FreshRSS_Search>
  403. */
  404. public function searches(): array {
  405. return $this->searches;
  406. }
  407. /** @return 'AND'|'OR'|'AND NOT'|'OR NOT' depending on how this BooleanSearch should be combined */
  408. public function operator(): string {
  409. return $this->operator;
  410. }
  411. /**
  412. * Wrap the existing searches in a single BooleanSearch if needed,
  413. * so that another search can be added as an additional restriction (AND).
  414. */
  415. private function wrapSearches(): void {
  416. if (count($this->searches) > 1 || (count($this->searches) > 0 && $this->searches[0] instanceof FreshRSS_Search)) {
  417. $wrap = new FreshRSS_BooleanSearch('');
  418. foreach ($this->searches as $existingSearch) {
  419. $wrap->add($existingSearch);
  420. }
  421. if (count($wrap->searches) > 0) {
  422. $this->searches = [$wrap];
  423. }
  424. }
  425. }
  426. /**
  427. * Add a search at the beginning of the Boolean expression, as an additional restriction (AND).
  428. * @param FreshRSS_BooleanSearch|FreshRSS_Search $search
  429. */
  430. public function prepend(FreshRSS_BooleanSearch|FreshRSS_Search $search): void {
  431. $this->wrapSearches();
  432. array_unshift($this->searches, $search);
  433. }
  434. /** @param FreshRSS_BooleanSearch|FreshRSS_Search $search */
  435. public function add(FreshRSS_BooleanSearch|FreshRSS_Search $search): void {
  436. $this->searches[] = $search;
  437. }
  438. /**
  439. * Modify the first compatible search of the Boolean expression, or add it at the beginning.
  440. * Useful to modify some search parameters.
  441. * @return FreshRSS_BooleanSearch a new instance, modified.
  442. */
  443. public function enforce(FreshRSS_Search $search): self {
  444. $result = clone $this;
  445. $result->raw_input = '';
  446. $result->expanded = null;
  447. $result->notExpanded = null;
  448. if (count($result->searches) === 1 && $result->searches[0] instanceof FreshRSS_Search) {
  449. $result->searches[0] = $result->searches[0]->enforce($search);
  450. return $result;
  451. }
  452. if (count($result->searches) === 2) {
  453. foreach ($result->searches as $booleanSearch) {
  454. if (!($booleanSearch instanceof FreshRSS_BooleanSearch)) {
  455. break;
  456. }
  457. if ($booleanSearch->operator() === 'AND') {
  458. if (count($booleanSearch->searches) === 1 && $booleanSearch->searches[0] instanceof FreshRSS_Search &&
  459. $booleanSearch->searches[0]->hasSameOperators($search)) {
  460. $booleanSearch->searches[0] = $search;
  461. return $result;
  462. }
  463. }
  464. }
  465. }
  466. $result->wrapSearches();
  467. array_unshift($result->searches, $search);
  468. return $result;
  469. }
  470. /**
  471. * Remove the first compatible search of the Boolean expression, if any.
  472. * Useful to modify some search parameters.
  473. * @return FreshRSS_BooleanSearch a new instance, modified.
  474. */
  475. public function remove(FreshRSS_Search $search): self {
  476. $result = clone $this;
  477. $result->raw_input = '';
  478. $result->expanded = null;
  479. $result->notExpanded = null;
  480. if (count($result->searches) === 1 && $result->searches[0] instanceof FreshRSS_Search) {
  481. $result->searches[0] = $result->searches[0]->remove($search);
  482. return $result;
  483. }
  484. if (count($result->searches) === 2) {
  485. foreach ($result->searches as $booleanSearch) {
  486. if (!($booleanSearch instanceof FreshRSS_BooleanSearch)) {
  487. break;
  488. }
  489. if ($booleanSearch->operator() === 'AND') {
  490. if (count($booleanSearch->searches) === 1 && $booleanSearch->searches[0] instanceof FreshRSS_Search &&
  491. $booleanSearch->searches[0]->hasSameOperators($search)) {
  492. array_shift($booleanSearch->searches);
  493. return $result;
  494. }
  495. }
  496. }
  497. }
  498. return $result;
  499. }
  500. /**
  501. * Return the minimum visibility (priority) level needed for this Boolean search, or null if it does not require any specific visibility level.
  502. * For instance, if the search includes some feed IDs then it will return PRIORITY_HIDDEN,
  503. * and if it includes some category IDs then it will return PRIORITY_CATEGORY.
  504. */
  505. public function needVisibility(): ?int {
  506. $minVisibility = FreshRSS_Feed::PRIORITY_IMPORTANT + 1;
  507. foreach ($this->searches as $search) {
  508. if ($search instanceof FreshRSS_BooleanSearch) {
  509. $visibility = $search->needVisibility();
  510. if ($visibility !== null) {
  511. $minVisibility = min($minVisibility, $visibility);
  512. }
  513. } elseif ($search instanceof FreshRSS_Search) {
  514. $visibility = $search->needVisibility();
  515. if ($visibility !== null) {
  516. $minVisibility = min($minVisibility, $visibility);
  517. }
  518. }
  519. }
  520. return $minVisibility < FreshRSS_Feed::PRIORITY_IMPORTANT ? $minVisibility : null;
  521. }
  522. private ?string $expanded = null;
  523. #[\Override]
  524. public function __toString(): string {
  525. if ($this->expanded === null) {
  526. $result = '';
  527. foreach ($this->searches as $search) {
  528. $part = $search->__toString();
  529. if ($part === '') {
  530. continue;
  531. }
  532. $operator = $search instanceof FreshRSS_BooleanSearch ? $search->operator : 'OR';
  533. if ((str_contains($part, ' ') || str_starts_with($part, '-')) && (count($this->searches) > 1 || in_array($operator, ['OR NOT', 'AND NOT'], true))) {
  534. $part = '(' . $part . ')';
  535. }
  536. $result .= match ($operator) {
  537. 'OR' => $result === '' ? '' : ' OR ',
  538. 'OR NOT' => $result === '' ? '-' : ' OR -',
  539. 'AND NOT' => $result === '' ? '-' : ' -',
  540. 'AND' => $result === '' ? '' : ' ',
  541. default => throw new InvalidArgumentException('Invalid operator: ' . $operator),
  542. } . $part;
  543. }
  544. $this->expanded = trim($result);
  545. }
  546. return $this->expanded;
  547. }
  548. private ?string $notExpanded = null;
  549. /**
  550. * @param bool $expandUserQueries Whether to expand user queries (saved searches) or not
  551. * @throws Minz_BadRequestException if the search is too long or if the parentheses are nested too deeply
  552. */
  553. public function toString(bool $expandUserQueries = true): string {
  554. if ($expandUserQueries) {
  555. return $this->__toString();
  556. }
  557. if ($this->notExpanded === null) {
  558. $this->notExpanded = (new FreshRSS_BooleanSearch($this->raw_input, expandUserQueries: false))->__toString();
  559. }
  560. return $this->notExpanded;
  561. }
  562. /** @return string Plain text search query. Must be XML-encoded or URL-encoded depending on the situation */
  563. #[Deprecated('Use __toString(expanded: false) instead')]
  564. public function getRawInput(): string {
  565. return $this->raw_input;
  566. }
  567. }