BooleanSearch.php 19 KB

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