Search.php 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287
  1. <?php
  2. declare(strict_types=1);
  3. require_once LIB_PATH . '/lib_date.php';
  4. /**
  5. * Contains a search from the search form.
  6. *
  7. * It allows to extract meaningful bits of the search and store them in a
  8. * convenient object
  9. */
  10. class FreshRSS_Search implements \Stringable {
  11. /**
  12. * This contains the user input string
  13. */
  14. private string $raw_input = '';
  15. // The following properties are extracted from the raw input
  16. /** @var list<string>|null */
  17. private ?array $entry_ids = null;
  18. /** @var list<int>|null */
  19. private ?array $feed_ids = null;
  20. /** @var list<int>|null */
  21. private ?array $category_ids = null;
  22. /** @var list<list<int>|'*'>|null */
  23. private $label_ids = null;
  24. /** @var list<list<string>>|null */
  25. private ?array $label_names = null;
  26. /** @var list<string>|null */
  27. private ?array $intitle = null;
  28. /** @var list<string>|null */
  29. private ?array $intitle_regex = null;
  30. /** @var list<string>|null */
  31. private ?array $intext = null;
  32. /** @var list<string>|null */
  33. private ?array $intext_regex = null;
  34. private ?string $input_date = null;
  35. private int|false|null $min_date = null;
  36. private int|false|null $max_date = null;
  37. private ?string $input_pubdate = null;
  38. private int|false|null $min_pubdate = null;
  39. private int|false|null $max_pubdate = null;
  40. private ?string $input_userdate = null;
  41. private int|false|null $min_userdate = null;
  42. private int|false|null $max_userdate = null;
  43. /** @var list<string>|null */
  44. private ?array $inurl = null;
  45. /** @var list<string>|null */
  46. private ?array $inurl_regex = null;
  47. /** @var list<string>|null */
  48. private ?array $author = null;
  49. /** @var list<string>|null */
  50. private ?array $author_regex = null;
  51. /** @var list<string>|null */
  52. private ?array $tags = null;
  53. /** @var list<string>|null */
  54. private ?array $tags_regex = null;
  55. /** @var list<string>|null */
  56. private ?array $search = null;
  57. /** @var list<string>|null */
  58. private ?array $search_regex = null;
  59. /** @var list<string>|null */
  60. private ?array $not_entry_ids = null;
  61. /** @var list<int>|null */
  62. private ?array $not_feed_ids = null;
  63. /** @var list<int>|null */
  64. private ?array $not_category_ids = null;
  65. /** @var list<list<int>|'*'>|null */
  66. private $not_label_ids = null;
  67. /** @var list<list<string>>|null */
  68. private ?array $not_label_names = null;
  69. /** @var list<string>|null */
  70. private ?array $not_intitle = null;
  71. /** @var list<string>|null */
  72. private ?array $not_intitle_regex = null;
  73. /** @var list<string>|null */
  74. private ?array $not_intext = null;
  75. /** @var list<string>|null */
  76. private ?array $not_intext_regex = null;
  77. private ?string $input_not_date = null;
  78. private int|false|null $not_min_date = null;
  79. private int|false|null $not_max_date = null;
  80. private ?string $input_not_pubdate = null;
  81. private int|false|null $not_min_pubdate = null;
  82. private int|false|null $not_max_pubdate = null;
  83. private ?string $input_not_userdate = null;
  84. private int|false|null $not_min_userdate = null;
  85. private int|false|null $not_max_userdate = null;
  86. /** @var list<string>|null */
  87. private ?array $not_inurl = null;
  88. /** @var list<string>|null */
  89. private ?array $not_inurl_regex = null;
  90. /** @var list<string>|null */
  91. private ?array $not_author = null;
  92. /** @var list<string>|null */
  93. private ?array $not_author_regex = null;
  94. /** @var list<string>|null */
  95. private ?array $not_tags = null;
  96. /** @var list<string>|null */
  97. private ?array $not_tags_regex = null;
  98. /** @var list<string>|null */
  99. private ?array $not_search = null;
  100. /** @var list<string>|null */
  101. private ?array $not_search_regex = null;
  102. public function __construct(string $input) {
  103. $input = self::cleanSearch($input);
  104. $input = self::unescape($input);
  105. $input = FreshRSS_BooleanSearch::unescapeLiterals($input);
  106. $this->raw_input = $input;
  107. $input = $this->parseNotEntryIds($input);
  108. $input = $this->parseNotFeedIds($input);
  109. $input = $this->parseNotCategoryIds($input);
  110. $input = $this->parseNotLabelIds($input);
  111. $input = $this->parseNotLabelNames($input);
  112. $input = $this->parseNotUserdateSearch($input);
  113. $input = $this->parseNotPubdateSearch($input);
  114. $input = $this->parseNotDateSearch($input);
  115. $input = $this->parseNotIntitleSearch($input);
  116. $input = $this->parseNotIntextSearch($input);
  117. $input = $this->parseNotAuthorSearch($input);
  118. $input = $this->parseNotInurlSearch($input);
  119. $input = $this->parseNotTagsSearch($input);
  120. $input = $this->parseEntryIds($input);
  121. $input = $this->parseFeedIds($input);
  122. $input = $this->parseCategoryIds($input);
  123. $input = $this->parseLabelIds($input);
  124. $input = $this->parseLabelNames($input);
  125. $input = $this->parseUserdateSearch($input);
  126. $input = $this->parsePubdateSearch($input);
  127. $input = $this->parseDateSearch($input);
  128. $input = $this->parseIntitleSearch($input);
  129. $input = $this->parseIntextSearch($input);
  130. $input = $this->parseAuthorSearch($input);
  131. $input = $this->parseInurlSearch($input);
  132. $input = $this->parseTagsSearch($input);
  133. $input = $this->parseQuotedSearch($input);
  134. $input = $this->parseNotSearch($input);
  135. $this->parseSearch($input);
  136. }
  137. private static function quote(string $s): string {
  138. if (str_starts_with($s, 'S:') || str_starts_with($s, 'search:')) {
  139. // Discard user queries
  140. return $s;
  141. }
  142. if (strpbrk($s, ' "\'\\/:') !== false || $s === '') {
  143. return '"' . addcslashes($s, '"') . '"';
  144. }
  145. return $s;
  146. }
  147. // TODO: Reuse as option for a string representation resolving and expanding date intervals
  148. // private static function dateIntervalToString(int|false|null $min, int|false|null $max): string {
  149. // if ($min === false) {
  150. // $min = null;
  151. // }
  152. // if ($max === false) {
  153. // $max = null;
  154. // }
  155. // if ($min === null && $max === null) {
  156. // return '';
  157. // }
  158. // $s = '';
  159. // if ($min !== null) {
  160. // $s .= date('Y-m-d\\TH:i:s', $min);
  161. // }
  162. // $s .= '/';
  163. // if ($max !== null) {
  164. // $s .= date('Y-m-d\\TH:i:s', $max);
  165. // }
  166. // return $s;
  167. // }
  168. /**
  169. * Return true if both searches have the same constraint parameters (even if the values differ), false otherwise.
  170. */
  171. public function hasSameOperators(FreshRSS_Search $search): bool {
  172. $properties = array_keys(get_object_vars($this));
  173. $properties = array_diff($properties, ['raw_input']); // raw_input is not a constraint parameter
  174. foreach ($properties as $property) {
  175. // @phpstan-ignore property.dynamicName, property.dynamicName
  176. if (gettype($this->$property) !== gettype($search->$property)) {
  177. if (is_string($property) && (str_contains($property, 'min_') || str_contains($property, 'max_'))) {
  178. // Process {min_*, max_*} pairs together (for dates)
  179. $mate = str_contains($property, 'min_') ? str_replace('min_', 'max_', $property) : str_replace('max_', 'min_', $property);
  180. // @phpstan-ignore property.dynamicName, property.dynamicName, property.dynamicName, property.dynamicName
  181. if (($this->$property !== null || $this->$mate !== null) !== ($search->$property !== null || $search->$mate !== null)) {
  182. return false;
  183. }
  184. } else {
  185. return false;
  186. }
  187. }
  188. // @phpstan-ignore property.dynamicName, property.dynamicName
  189. if (is_array($this->$property) && is_array($search->$property)) {
  190. // @phpstan-ignore property.dynamicName, property.dynamicName
  191. if (count($this->$property) !== count($search->$property)) {
  192. return false;
  193. }
  194. }
  195. }
  196. return true;
  197. }
  198. /**
  199. * Modifies this search by enforcing the constraint parameters of another search.
  200. * @return FreshRSS_Search a new instance, modified.
  201. */
  202. public function enforce(FreshRSS_Search $search): self {
  203. $result = clone $this;
  204. $properties = array_keys(get_object_vars($result));
  205. $properties = array_diff($properties, ['raw_input']); // raw_input is not a constraint parameter
  206. $result->raw_input = '';
  207. foreach ($properties as $property) {
  208. // @phpstan-ignore property.dynamicName
  209. if ($search->$property !== null) {
  210. // @phpstan-ignore property.dynamicName, property.dynamicName
  211. $result->$property = $search->$property;
  212. if (is_string($property) && (str_contains($property, 'min_') || str_contains($property, 'max_'))) {
  213. // Process {min_*, max_*} pairs together (for dates)
  214. $mate = str_contains($property, 'min_') ? str_replace('min_', 'max_', $property) : str_replace('max_', 'min_', $property);
  215. // @phpstan-ignore property.dynamicName, property.dynamicName
  216. $result->$mate = $search->$mate;
  217. }
  218. }
  219. }
  220. return $result;
  221. }
  222. /**
  223. * Modifies this search by removing the constraints given by another search.
  224. * @return FreshRSS_Search a new instance, modified.
  225. */
  226. public function remove(FreshRSS_Search $search): self {
  227. $result = clone $this;
  228. $properties = array_keys(get_object_vars($result));
  229. $properties = array_diff($properties, ['raw_input']); // raw_input is not a constraint parameter
  230. $result->raw_input = '';
  231. foreach ($properties as $property) {
  232. // @phpstan-ignore property.dynamicName
  233. if ($search->$property !== null) {
  234. // @phpstan-ignore property.dynamicName
  235. $result->$property = null;
  236. if (is_string($property) && (str_contains($property, 'min_') || str_contains($property, 'max_'))) {
  237. // Process {min_*, max_*} pairs together (for dates)
  238. $mate = str_contains($property, 'min_') ? str_replace('min_', 'max_', $property) : str_replace('max_', 'min_', $property);
  239. // @phpstan-ignore property.dynamicName
  240. $result->$mate = null;
  241. }
  242. }
  243. }
  244. return $result;
  245. }
  246. #[\Override]
  247. public function __toString(): string {
  248. $result = '';
  249. if ($this->entry_ids !== null) {
  250. $result .= ' e:' . implode(',', $this->entry_ids);
  251. }
  252. if ($this->feed_ids !== null) {
  253. $result .= ' f:' . implode(',', $this->feed_ids);
  254. }
  255. if ($this->category_ids !== null) {
  256. $result .= ' c:' . implode(',', $this->category_ids);
  257. }
  258. if ($this->label_ids !== null) {
  259. foreach ($this->label_ids as $ids) {
  260. $result .= ' L:' . (is_array($ids) ? implode(',', $ids) : $ids);
  261. }
  262. }
  263. if ($this->label_names !== null) {
  264. foreach ($this->label_names as $names) {
  265. $result .= ' labels:' . self::quote(implode(',', $names));
  266. }
  267. }
  268. if ($this->input_userdate !== null) {
  269. $result .= ' userdate:' . $this->input_userdate;
  270. }
  271. if ($this->input_pubdate !== null) {
  272. $result .= ' pubdate:' . $this->input_pubdate;
  273. }
  274. if ($this->input_date !== null) {
  275. $result .= ' date:' . $this->input_date;
  276. }
  277. if ($this->intitle_regex !== null) {
  278. foreach ($this->intitle_regex as $s) {
  279. $result .= ' intitle:' . $s;
  280. }
  281. }
  282. if ($this->intitle !== null) {
  283. foreach ($this->intitle as $s) {
  284. $result .= ' intitle:' . self::quote($s);
  285. }
  286. }
  287. if ($this->intext_regex !== null) {
  288. foreach ($this->intext_regex as $s) {
  289. $result .= ' intext:' . $s;
  290. }
  291. }
  292. if ($this->intext !== null) {
  293. foreach ($this->intext as $s) {
  294. $result .= ' intext:' . self::quote($s);
  295. }
  296. }
  297. if ($this->author_regex !== null) {
  298. foreach ($this->author_regex as $s) {
  299. $result .= ' author:' . $s;
  300. }
  301. }
  302. if ($this->author !== null) {
  303. foreach ($this->author as $s) {
  304. $result .= ' author:' . self::quote($s);
  305. }
  306. }
  307. if ($this->inurl_regex !== null) {
  308. foreach ($this->inurl_regex as $s) {
  309. $result .= ' inurl:' . $s;
  310. }
  311. }
  312. if ($this->inurl !== null) {
  313. foreach ($this->inurl as $s) {
  314. $result .= ' inurl:' . self::quote($s);
  315. }
  316. }
  317. if ($this->tags_regex !== null) {
  318. foreach ($this->tags_regex as $s) {
  319. $result .= ' #' . $s;
  320. }
  321. }
  322. if ($this->tags !== null) {
  323. foreach ($this->tags as $s) {
  324. $result .= ' #' . self::quote($s);
  325. }
  326. }
  327. if ($this->search_regex !== null) {
  328. foreach ($this->search_regex as $s) {
  329. $result .= ' ' . $s;
  330. }
  331. }
  332. if ($this->search !== null) {
  333. foreach ($this->search as $s) {
  334. $result .= ' ' . self::quote($s);
  335. }
  336. }
  337. if ($this->not_entry_ids !== null) {
  338. $result .= ' -e:' . implode(',', $this->not_entry_ids);
  339. }
  340. if ($this->not_feed_ids !== null) {
  341. $result .= ' -f:' . implode(',', $this->not_feed_ids);
  342. }
  343. if ($this->not_category_ids !== null) {
  344. $result .= ' -c:' . implode(',', $this->not_category_ids);
  345. }
  346. if ($this->not_label_ids !== null) {
  347. foreach ($this->not_label_ids as $ids) {
  348. $result .= ' -L:' . (is_array($ids) ? implode(',', $ids) : $ids);
  349. }
  350. }
  351. if ($this->not_label_names !== null) {
  352. foreach ($this->not_label_names as $names) {
  353. $result .= ' -labels:' . self::quote(implode(',', $names));
  354. }
  355. }
  356. if ($this->input_not_userdate !== null) {
  357. $result .= ' -userdate:' . $this->input_not_userdate;
  358. }
  359. if ($this->input_not_pubdate !== null) {
  360. $result .= ' -pubdate:' . $this->input_not_pubdate;
  361. }
  362. if ($this->input_not_date !== null) {
  363. $result .= ' -date:' . $this->input_not_date;
  364. }
  365. if ($this->not_intitle_regex !== null) {
  366. foreach ($this->not_intitle_regex as $s) {
  367. $result .= ' -intitle:' . $s;
  368. }
  369. }
  370. if ($this->not_intitle !== null) {
  371. foreach ($this->not_intitle as $s) {
  372. $result .= ' -intitle:' . self::quote($s);
  373. }
  374. }
  375. if ($this->not_intext_regex !== null) {
  376. foreach ($this->not_intext_regex as $s) {
  377. $result .= ' -intext:' . $s;
  378. }
  379. }
  380. if ($this->not_intext !== null) {
  381. foreach ($this->not_intext as $s) {
  382. $result .= ' -intext:' . self::quote($s);
  383. }
  384. }
  385. if ($this->not_author_regex !== null) {
  386. foreach ($this->not_author_regex as $s) {
  387. $result .= ' -author:' . $s;
  388. }
  389. }
  390. if ($this->not_author !== null) {
  391. foreach ($this->not_author as $s) {
  392. $result .= ' -author:' . self::quote($s);
  393. }
  394. }
  395. if ($this->not_inurl_regex !== null) {
  396. foreach ($this->not_inurl_regex as $s) {
  397. $result .= ' -inurl:' . $s;
  398. }
  399. }
  400. if ($this->not_inurl !== null) {
  401. foreach ($this->not_inurl as $s) {
  402. $result .= ' -inurl:' . self::quote($s);
  403. }
  404. }
  405. if ($this->not_tags_regex !== null) {
  406. foreach ($this->not_tags_regex as $s) {
  407. $result .= ' -#' . $s;
  408. }
  409. }
  410. if ($this->not_tags !== null) {
  411. foreach ($this->not_tags as $s) {
  412. $result .= ' -#' . self::quote($s);
  413. }
  414. }
  415. if ($this->not_search_regex !== null) {
  416. foreach ($this->not_search_regex as $s) {
  417. $result .= ' -' . $s;
  418. }
  419. }
  420. if ($this->not_search !== null) {
  421. foreach ($this->not_search as $s) {
  422. $result .= ' -' . self::quote($s);
  423. }
  424. }
  425. return trim($result);
  426. }
  427. #[Deprecated('Use __toString() instead')]
  428. public function getRawInput(): string {
  429. return $this->raw_input;
  430. }
  431. /** @return list<string>|null */
  432. public function getEntryIds(): ?array {
  433. return $this->entry_ids;
  434. }
  435. /** @return list<string>|null */
  436. public function getNotEntryIds(): ?array {
  437. return $this->not_entry_ids;
  438. }
  439. /** @return list<int>|null */
  440. public function getFeedIds(): ?array {
  441. return $this->feed_ids;
  442. }
  443. /** @return list<int>|null */
  444. public function getNotFeedIds(): ?array {
  445. return $this->not_feed_ids;
  446. }
  447. /** @return list<int>|null */
  448. public function getCategoryIds(): ?array {
  449. return $this->category_ids;
  450. }
  451. /** @return list<int>|null */
  452. public function getNotCategoryIds(): ?array {
  453. return $this->not_category_ids;
  454. }
  455. /** @return list<list<int>|'*'>|null */
  456. public function getLabelIds(): array|null {
  457. return $this->label_ids;
  458. }
  459. /** @return list<list<int>|'*'>|null */
  460. public function getNotLabelIds(): array|null {
  461. return $this->not_label_ids;
  462. }
  463. /** @return list<list<string>>|null */
  464. public function getLabelNames(bool $plaintext = false): ?array {
  465. return $plaintext ? $this->label_names : Minz_Helper::htmlspecialchars_utf8($this->label_names, ENT_NOQUOTES);
  466. }
  467. /** @return list<list<string>>|null */
  468. public function getNotLabelNames(bool $plaintext = false): ?array {
  469. return $plaintext ? $this->not_label_names : Minz_Helper::htmlspecialchars_utf8($this->not_label_names, ENT_NOQUOTES);
  470. }
  471. /** @return list<string>|null */
  472. public function getIntitle(bool $plaintext = false): ?array {
  473. return $plaintext ? $this->intitle : Minz_Helper::htmlspecialchars_utf8($this->intitle, ENT_NOQUOTES);
  474. }
  475. /** @return list<string>|null */
  476. public function getIntitleRegex(): ?array {
  477. return $this->intitle_regex;
  478. }
  479. /** @return list<string>|null */
  480. public function getNotIntitle(bool $plaintext = false): ?array {
  481. return $plaintext ? $this->not_intitle : Minz_Helper::htmlspecialchars_utf8($this->not_intitle, ENT_NOQUOTES);
  482. }
  483. /** @return list<string>|null */
  484. public function getNotIntitleRegex(): ?array {
  485. return $this->not_intitle_regex;
  486. }
  487. /** @return list<string>|null */
  488. public function getIntext(bool $plaintext = false): ?array {
  489. return $plaintext ? $this->intext : Minz_Helper::htmlspecialchars_utf8($this->intext, ENT_NOQUOTES);
  490. }
  491. /** @return list<string>|null */
  492. public function getIntextRegex(): ?array {
  493. return $this->intext_regex;
  494. }
  495. /** @return list<string>|null */
  496. public function getNotIntext(bool $plaintext = false): ?array {
  497. return $plaintext ? $this->not_intext : Minz_Helper::htmlspecialchars_utf8($this->not_intext, ENT_NOQUOTES);
  498. }
  499. /** @return list<string>|null */
  500. public function getNotIntextRegex(): ?array {
  501. return $this->not_intext_regex;
  502. }
  503. public function getMinDate(): ?int {
  504. return $this->min_date ?: null;
  505. }
  506. public function getNotMinDate(): ?int {
  507. return $this->not_min_date ?: null;
  508. }
  509. public function setMinDate(int $value): void {
  510. $this->min_date = $value;
  511. }
  512. public function getMaxDate(): ?int {
  513. return $this->max_date ?: null;
  514. }
  515. public function getNotMaxDate(): ?int {
  516. return $this->not_max_date ?: null;
  517. }
  518. public function setMaxDate(int $value): void {
  519. $this->max_date = $value;
  520. }
  521. public function getMinPubdate(): ?int {
  522. return $this->min_pubdate ?: null;
  523. }
  524. public function getNotMinPubdate(): ?int {
  525. return $this->not_min_pubdate ?: null;
  526. }
  527. public function getMaxPubdate(): ?int {
  528. return $this->max_pubdate ?: null;
  529. }
  530. public function getNotMaxPubdate(): ?int {
  531. return $this->not_max_pubdate ?: null;
  532. }
  533. public function setMaxPubdate(int $value): void {
  534. $this->max_pubdate = $value;
  535. }
  536. public function getMinUserdate(): ?int {
  537. return $this->min_userdate ?: null;
  538. }
  539. public function getNotMinUserdate(): ?int {
  540. return $this->not_min_userdate ?: null;
  541. }
  542. public function getMaxUserdate(): ?int {
  543. return $this->max_userdate ?: null;
  544. }
  545. public function getNotMaxUserdate(): ?int {
  546. return $this->not_max_userdate ?: null;
  547. }
  548. /** @return list<string>|null */
  549. public function getInurl(bool $plaintext = false): ?array {
  550. return $plaintext ? $this->inurl : Minz_Helper::htmlspecialchars_utf8($this->inurl, ENT_NOQUOTES);
  551. }
  552. /** @return list<string>|null */
  553. public function getInurlRegex(): ?array {
  554. return $this->inurl_regex;
  555. }
  556. /** @return list<string>|null */
  557. public function getNotInurl(bool $plaintext = false): ?array {
  558. return $plaintext ? $this->not_inurl : Minz_Helper::htmlspecialchars_utf8($this->not_inurl, ENT_NOQUOTES);
  559. }
  560. /** @return list<string>|null */
  561. public function getNotInurlRegex(): ?array {
  562. return $this->not_inurl_regex;
  563. }
  564. /** @return list<string>|null */
  565. public function getAuthor(bool $plaintext = false): ?array {
  566. return $plaintext ? $this->author : Minz_Helper::htmlspecialchars_utf8($this->author, ENT_NOQUOTES);
  567. }
  568. /** @return list<string>|null */
  569. public function getAuthorRegex(): ?array {
  570. return $this->author_regex;
  571. }
  572. /** @return list<string>|null */
  573. public function getNotAuthor(bool $plaintext = false): ?array {
  574. return $plaintext ? $this->not_author : Minz_Helper::htmlspecialchars_utf8($this->not_author, ENT_NOQUOTES);
  575. }
  576. /** @return list<string>|null */
  577. public function getNotAuthorRegex(): ?array {
  578. return $this->not_author_regex;
  579. }
  580. /** @return list<string>|null */
  581. public function getTags(bool $plaintext = false): ?array {
  582. return $plaintext ? $this->tags : Minz_Helper::htmlspecialchars_utf8($this->tags, ENT_NOQUOTES);
  583. }
  584. /** @return list<string>|null */
  585. public function getTagsRegex(): ?array {
  586. return $this->tags_regex;
  587. }
  588. /** @return list<string>|null */
  589. public function getNotTags(bool $plaintext = false): ?array {
  590. return $plaintext ? $this->not_tags : Minz_Helper::htmlspecialchars_utf8($this->not_tags, ENT_NOQUOTES);
  591. }
  592. /** @return list<string>|null */
  593. public function getNotTagsRegex(): ?array {
  594. return $this->not_tags_regex;
  595. }
  596. /** @return list<string>|null */
  597. public function getSearch(bool $plaintext = false): ?array {
  598. return $plaintext ? $this->search : Minz_Helper::htmlspecialchars_utf8($this->search, ENT_NOQUOTES);
  599. }
  600. /** @return list<string>|null */
  601. public function getSearchRegex(): ?array {
  602. return $this->search_regex;
  603. }
  604. /** @return list<string>|null */
  605. public function getNotSearch(bool $plaintext = false): ?array {
  606. return $plaintext ? $this->not_search : Minz_Helper::htmlspecialchars_utf8($this->not_search, ENT_NOQUOTES);
  607. }
  608. /** @return list<string>|null */
  609. public function getNotSearchRegex(): ?array {
  610. return $this->not_search_regex;
  611. }
  612. /**
  613. * @param list<string>|null $anArray
  614. * @return list<string>
  615. */
  616. private static function removeEmptyValues(?array $anArray): array {
  617. return empty($anArray) ? [] : array_values(array_filter($anArray, static fn(string $value) => $value !== ''));
  618. }
  619. /**
  620. * @param list<string>|string $value
  621. * @return ($value is string ? string : list<string>)
  622. */
  623. private static function decodeSpaces(array|string $value): array|string {
  624. if (is_array($value)) {
  625. foreach ($value as &$val) {
  626. $val = self::decodeSpaces($val);
  627. }
  628. } else {
  629. $value = trim(str_replace('+', ' ', $value));
  630. }
  631. // @phpstan-ignore return.type
  632. return $value;
  633. }
  634. /**
  635. * Parse the search string to find entry (article) IDs.
  636. */
  637. private function parseEntryIds(string $input): string {
  638. if (preg_match_all('/\\be:(?P<search>[0-9,]*)/', $input, $matches)) {
  639. $input = str_replace($matches[0], '', $input);
  640. $ids_lists = $matches['search'];
  641. $this->entry_ids = [];
  642. foreach ($ids_lists as $ids_list) {
  643. $entry_ids = explode(',', $ids_list);
  644. $entry_ids = self::removeEmptyValues($entry_ids);
  645. if (!empty($entry_ids)) {
  646. $this->entry_ids = array_merge($this->entry_ids, $entry_ids);
  647. }
  648. }
  649. }
  650. return $input;
  651. }
  652. private function parseNotEntryIds(string $input): string {
  653. if (preg_match_all('/(?<=[\\s(]|^)[!-]e:(?P<search>[0-9,]*)/', $input, $matches)) {
  654. $input = str_replace($matches[0], '', $input);
  655. $ids_lists = $matches['search'];
  656. $this->not_entry_ids = [];
  657. foreach ($ids_lists as $ids_list) {
  658. $entry_ids = explode(',', $ids_list);
  659. $entry_ids = self::removeEmptyValues($entry_ids);
  660. if (!empty($entry_ids)) {
  661. $this->not_entry_ids = array_merge($this->not_entry_ids, $entry_ids);
  662. }
  663. }
  664. }
  665. return $input;
  666. }
  667. private function parseFeedIds(string $input): string {
  668. if (preg_match_all('/\\bf:(?P<search>[0-9,]*)/', $input, $matches)) {
  669. $input = str_replace($matches[0], '', $input);
  670. $ids_lists = $matches['search'];
  671. $this->feed_ids = [];
  672. foreach ($ids_lists as $ids_list) {
  673. $feed_ids = explode(',', $ids_list);
  674. $feed_ids = self::removeEmptyValues($feed_ids);
  675. /** @var list<int> $feed_ids */
  676. $feed_ids = array_map('intval', $feed_ids);
  677. if (!empty($feed_ids)) {
  678. $this->feed_ids = array_merge($this->feed_ids, $feed_ids);
  679. }
  680. }
  681. }
  682. return $input;
  683. }
  684. private function parseNotFeedIds(string $input): string {
  685. if (preg_match_all('/(?<=[\\s(]|^)[!-]f:(?P<search>[0-9,]*)/', $input, $matches)) {
  686. $input = str_replace($matches[0], '', $input);
  687. $ids_lists = $matches['search'];
  688. $this->not_feed_ids = [];
  689. foreach ($ids_lists as $ids_list) {
  690. $feed_ids = explode(',', $ids_list);
  691. $feed_ids = self::removeEmptyValues($feed_ids);
  692. /** @var list<int> $feed_ids */
  693. $feed_ids = array_map('intval', $feed_ids);
  694. if (!empty($feed_ids)) {
  695. $this->not_feed_ids = array_merge($this->not_feed_ids, $feed_ids);
  696. }
  697. }
  698. }
  699. return $input;
  700. }
  701. private function parseCategoryIds(string $input): string {
  702. if (preg_match_all('/\\bc:(?P<search>[0-9,]*)/', $input, $matches)) {
  703. $input = str_replace($matches[0], '', $input);
  704. $ids_lists = $matches['search'];
  705. $this->category_ids = [];
  706. foreach ($ids_lists as $ids_list) {
  707. $category_ids = explode(',', $ids_list);
  708. $category_ids = self::removeEmptyValues($category_ids);
  709. /** @var list<int> $category_ids */
  710. $category_ids = array_map('intval', $category_ids);
  711. if (!empty($category_ids)) {
  712. $this->category_ids = array_merge($this->category_ids, $category_ids);
  713. }
  714. }
  715. }
  716. return $input;
  717. }
  718. private function parseNotCategoryIds(string $input): string {
  719. if (preg_match_all('/(?<=[\\s(]|^)[!-]c:(?P<search>[0-9,]*)/', $input, $matches)) {
  720. $input = str_replace($matches[0], '', $input);
  721. $ids_lists = $matches['search'];
  722. $this->not_category_ids = [];
  723. foreach ($ids_lists as $ids_list) {
  724. $category_ids = explode(',', $ids_list);
  725. $category_ids = self::removeEmptyValues($category_ids);
  726. /** @var list<int> $category_ids */
  727. $category_ids = array_map('intval', $category_ids);
  728. if (!empty($category_ids)) {
  729. $this->not_category_ids = array_merge($this->not_category_ids, $category_ids);
  730. }
  731. }
  732. }
  733. return $input;
  734. }
  735. /**
  736. * Parse the search string to find tags (labels) IDs.
  737. */
  738. private function parseLabelIds(string $input): string {
  739. if (preg_match_all('/\\b[lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) {
  740. $input = str_replace($matches[0], '', $input);
  741. $ids_lists = $matches['search'];
  742. $this->label_ids = [];
  743. foreach ($ids_lists as $ids_list) {
  744. if ($ids_list === '*') {
  745. $this->label_ids[] = '*';
  746. break;
  747. }
  748. $label_ids = explode(',', $ids_list);
  749. $label_ids = self::removeEmptyValues($label_ids);
  750. /** @var list<int> $label_ids */
  751. $label_ids = array_map('intval', $label_ids);
  752. if (!empty($label_ids)) {
  753. $this->label_ids[] = $label_ids;
  754. }
  755. }
  756. }
  757. return $input;
  758. }
  759. private function parseNotLabelIds(string $input): string {
  760. if (preg_match_all('/(?<=[\\s(]|^)[!-][lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) {
  761. $input = str_replace($matches[0], '', $input);
  762. $ids_lists = $matches['search'];
  763. $this->not_label_ids = [];
  764. foreach ($ids_lists as $ids_list) {
  765. if ($ids_list === '*') {
  766. $this->not_label_ids[] = '*';
  767. break;
  768. }
  769. $label_ids = explode(',', $ids_list);
  770. $label_ids = self::removeEmptyValues($label_ids);
  771. /** @var list<int> $label_ids */
  772. $label_ids = array_map('intval', $label_ids);
  773. if (!empty($label_ids)) {
  774. $this->not_label_ids[] = $label_ids;
  775. }
  776. }
  777. }
  778. return $input;
  779. }
  780. /**
  781. * Parse the search string to find tags (labels) names.
  782. */
  783. private function parseLabelNames(string $input): string {
  784. $names_lists = [];
  785. if (preg_match_all('/\\blabels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  786. $names_lists = $matches['search'];
  787. $input = str_replace($matches[0], '', $input);
  788. }
  789. if (preg_match_all('/\\blabels?:(?P<search>[^\s"]*)/', $input, $matches)) {
  790. $names_lists = array_merge($names_lists, $matches['search']);
  791. $input = str_replace($matches[0], '', $input);
  792. }
  793. if (!empty($names_lists)) {
  794. $this->label_names = [];
  795. foreach ($names_lists as $names_list) {
  796. $names_array = explode(',', $names_list);
  797. $names_array = self::removeEmptyValues($names_array);
  798. if (!empty($names_array)) {
  799. $this->label_names[] = $names_array;
  800. }
  801. }
  802. }
  803. return $input;
  804. }
  805. /**
  806. * Parse the search string to find tags (labels) names to exclude.
  807. */
  808. private function parseNotLabelNames(string $input): string {
  809. $names_lists = [];
  810. if (preg_match_all('/(?<=[\\s(]|^)[!-]labels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  811. $names_lists = $matches['search'];
  812. $input = str_replace($matches[0], '', $input);
  813. }
  814. if (preg_match_all('/(?<=[\\s(]|^)[!-]labels?:(?P<search>[^\\s"]*)/', $input, $matches)) {
  815. $names_lists = array_merge($names_lists, $matches['search']);
  816. $input = str_replace($matches[0], '', $input);
  817. }
  818. if (!empty($names_lists)) {
  819. $this->not_label_names = [];
  820. foreach ($names_lists as $names_list) {
  821. $names_array = explode(',', $names_list);
  822. $names_array = self::removeEmptyValues($names_array);
  823. if (!empty($names_array)) {
  824. $this->not_label_names[] = $names_array;
  825. }
  826. }
  827. }
  828. return $input;
  829. }
  830. /**
  831. * Parse the search string to find intitle keyword and the search related to it.
  832. */
  833. private function parseIntitleSearch(string $input): string {
  834. if (preg_match_all('#\\bintitle:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
  835. $this->intitle_regex = $matches['search'];
  836. $input = str_replace($matches[0], '', $input);
  837. }
  838. if (preg_match_all('/\\bintitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  839. $this->intitle = $matches['search'];
  840. $input = str_replace($matches[0], '', $input);
  841. }
  842. if (preg_match_all('/\\bintitle:(?P<search>[^\s"]*)/', $input, $matches)) {
  843. $this->intitle = array_merge($this->intitle ?? [], $matches['search']);
  844. $input = str_replace($matches[0], '', $input);
  845. }
  846. $this->intitle = self::removeEmptyValues($this->intitle);
  847. if (empty($this->intitle)) {
  848. $this->intitle = null;
  849. }
  850. return $input;
  851. }
  852. private function parseNotIntitleSearch(string $input): string {
  853. if (preg_match_all('#(?<=[\\s(]|^)[!-]intitle:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
  854. $this->not_intitle_regex = $matches['search'];
  855. $input = str_replace($matches[0], '', $input);
  856. }
  857. if (preg_match_all('/(?<=[\\s(]|^)[!-]intitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  858. $this->not_intitle = $matches['search'];
  859. $input = str_replace($matches[0], '', $input);
  860. }
  861. if (preg_match_all('/(?<=[\\s(]|^)[!-]intitle:(?P<search>[^\s"]*)/', $input, $matches)) {
  862. $this->not_intitle = array_merge($this->not_intitle ?? [], $matches['search']);
  863. $input = str_replace($matches[0], '', $input);
  864. }
  865. $this->not_intitle = self::removeEmptyValues($this->not_intitle);
  866. if (empty($this->not_intitle)) {
  867. $this->not_intitle = null;
  868. }
  869. return $input;
  870. }
  871. /**
  872. * Parse the search string to find intext keyword and the search related to it.
  873. */
  874. private function parseIntextSearch(string $input): string {
  875. if (preg_match_all('#\\bintext:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
  876. $this->intext_regex = $matches['search'];
  877. $input = str_replace($matches[0], '', $input);
  878. }
  879. if (preg_match_all('/\\bintext:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  880. $this->intext = $matches['search'];
  881. $input = str_replace($matches[0], '', $input);
  882. }
  883. if (preg_match_all('/\\bintext:(?P<search>[^\s"]*)/', $input, $matches)) {
  884. $this->intext = array_merge($this->intext ?? [], $matches['search']);
  885. $input = str_replace($matches[0], '', $input);
  886. }
  887. $this->intext = self::removeEmptyValues($this->intext);
  888. if (empty($this->intext)) {
  889. $this->intext = null;
  890. }
  891. return $input;
  892. }
  893. private function parseNotIntextSearch(string $input): string {
  894. if (preg_match_all('#(?<=[\\s(]|^)[!-]intext:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
  895. $this->not_intext_regex = $matches['search'];
  896. $input = str_replace($matches[0], '', $input);
  897. }
  898. if (preg_match_all('/(?<=[\\s(]|^)[!-]intext:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  899. $this->not_intext = $matches['search'];
  900. $input = str_replace($matches[0], '', $input);
  901. }
  902. if (preg_match_all('/(?<=[\\s(]|^)[!-]intext:(?P<search>[^\s"]*)/', $input, $matches)) {
  903. $this->not_intext = array_merge($this->not_intext ?? [], $matches['search']);
  904. $input = str_replace($matches[0], '', $input);
  905. }
  906. $this->not_intext = self::removeEmptyValues($this->not_intext);
  907. if (empty($this->not_intext)) {
  908. $this->not_intext = null;
  909. }
  910. return $input;
  911. }
  912. /**
  913. * Parse the search string to find author keyword and the search related to it.
  914. * The search is the first word following the keyword except when using
  915. * a delimiter. Supported delimiters are single quote (') and double quotes (").
  916. */
  917. private function parseAuthorSearch(string $input): string {
  918. if (preg_match_all('#\\bauthor:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
  919. $this->author_regex = $matches['search'];
  920. $input = str_replace($matches[0], '', $input);
  921. }
  922. if (preg_match_all('/\\bauthor:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  923. $this->author = $matches['search'];
  924. $input = str_replace($matches[0], '', $input);
  925. }
  926. if (preg_match_all('/\\bauthor:(?P<search>[^\s"]*)/', $input, $matches)) {
  927. $this->author = array_merge($this->author ?? [], $matches['search']);
  928. $input = str_replace($matches[0], '', $input);
  929. }
  930. $this->author = self::removeEmptyValues($this->author);
  931. if (empty($this->author)) {
  932. $this->author = null;
  933. }
  934. return $input;
  935. }
  936. private function parseNotAuthorSearch(string $input): string {
  937. if (preg_match_all('#(?<=[\\s(]|^)[!-]author:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
  938. $this->not_author_regex = $matches['search'];
  939. $input = str_replace($matches[0], '', $input);
  940. }
  941. if (preg_match_all('/(?<=[\\s(]|^)[!-]author:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  942. $this->not_author = $matches['search'];
  943. $input = str_replace($matches[0], '', $input);
  944. }
  945. if (preg_match_all('/(?<=[\\s(]|^)[!-]author:(?P<search>[^\s"]*)/', $input, $matches)) {
  946. $this->not_author = array_merge($this->not_author ?? [], $matches['search']);
  947. $input = str_replace($matches[0], '', $input);
  948. }
  949. $this->not_author = self::removeEmptyValues($this->not_author);
  950. if (empty($this->not_author)) {
  951. $this->not_author = null;
  952. }
  953. return $input;
  954. }
  955. /**
  956. * Parse the search string to find inurl keyword and the search related to it.
  957. * The search is the first word following the keyword.
  958. */
  959. private function parseInurlSearch(string $input): string {
  960. if (preg_match_all('#\\binurl:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
  961. $this->inurl_regex = $matches['search'];
  962. $input = str_replace($matches[0], '', $input);
  963. }
  964. if (preg_match_all('/\\binurl:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  965. $this->inurl = $matches['search'];
  966. $input = str_replace($matches[0], '', $input);
  967. }
  968. if (preg_match_all('/\\binurl:(?P<search>[^\\s]*)/', $input, $matches)) {
  969. $this->inurl = $matches['search'];
  970. $input = str_replace($matches[0], '', $input);
  971. }
  972. $this->inurl = self::removeEmptyValues($this->inurl);
  973. if (empty($this->inurl)) {
  974. $this->inurl = null;
  975. }
  976. return $input;
  977. }
  978. private function parseNotInurlSearch(string $input): string {
  979. if (preg_match_all('#(?<=[\\s(]|^)[!-]inurl:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
  980. $this->not_inurl_regex = $matches['search'];
  981. $input = str_replace($matches[0], '', $input);
  982. }
  983. if (preg_match_all('/(?<=[\\s(]|^)[!-]inurl:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  984. $this->not_inurl = $matches['search'];
  985. $input = str_replace($matches[0], '', $input);
  986. }
  987. if (preg_match_all('/(?<=[\\s(]|^)[!-]inurl:(?P<search>[^\\s]*)/', $input, $matches)) {
  988. $this->not_inurl = $matches['search'];
  989. $input = str_replace($matches[0], '', $input);
  990. }
  991. $this->not_inurl = self::removeEmptyValues($this->not_inurl);
  992. if (empty($this->not_inurl)) {
  993. $this->not_inurl = null;
  994. }
  995. return $input;
  996. }
  997. /**
  998. * Parse the search string to find date keyword and the search related to it.
  999. * The search is the first word following the keyword.
  1000. */
  1001. private function parseDateSearch(string $input): string {
  1002. if (preg_match_all('/\\bdate:(?P<search>[^\\s]*)/', $input, $matches)) {
  1003. $input = str_replace($matches[0], '', $input);
  1004. $dates = self::removeEmptyValues($matches['search']);
  1005. if (!empty($dates[0])) {
  1006. [$this->min_date, $this->max_date] = parseDateInterval($dates[0]);
  1007. if (is_int($this->min_date) || is_int($this->max_date)) {
  1008. $this->input_date = $dates[0];
  1009. }
  1010. }
  1011. }
  1012. return $input;
  1013. }
  1014. private function parseNotDateSearch(string $input): string {
  1015. if (preg_match_all('/(?<=[\\s(]|^)[!-]date:(?P<search>[^\\s]*)/', $input, $matches)) {
  1016. $input = str_replace($matches[0], '', $input);
  1017. $dates = self::removeEmptyValues($matches['search']);
  1018. if (!empty($dates[0])) {
  1019. [$this->not_min_date, $this->not_max_date] = parseDateInterval($dates[0]);
  1020. if (is_int($this->not_min_date) || is_int($this->not_max_date)) {
  1021. $this->input_not_date = $dates[0];
  1022. }
  1023. }
  1024. }
  1025. return $input;
  1026. }
  1027. /**
  1028. * Parse the search string to find pubdate keyword and the search related to it.
  1029. * The search is the first word following the keyword.
  1030. */
  1031. private function parsePubdateSearch(string $input): string {
  1032. if (preg_match_all('/\\bpubdate:(?P<search>[^\\s]*)/', $input, $matches)) {
  1033. $input = str_replace($matches[0], '', $input);
  1034. $dates = self::removeEmptyValues($matches['search']);
  1035. if (!empty($dates[0])) {
  1036. [$this->min_pubdate, $this->max_pubdate] = parseDateInterval($dates[0]);
  1037. if (is_int($this->min_pubdate) || is_int($this->max_pubdate)) {
  1038. $this->input_pubdate = $dates[0];
  1039. }
  1040. }
  1041. }
  1042. return $input;
  1043. }
  1044. private function parseNotPubdateSearch(string $input): string {
  1045. if (preg_match_all('/(?<=[\\s(]|^)[!-]pubdate:(?P<search>[^\\s]*)/', $input, $matches)) {
  1046. $input = str_replace($matches[0], '', $input);
  1047. $dates = self::removeEmptyValues($matches['search']);
  1048. if (!empty($dates[0])) {
  1049. [$this->not_min_pubdate, $this->not_max_pubdate] = parseDateInterval($dates[0]);
  1050. if (is_int($this->not_min_pubdate) || is_int($this->not_max_pubdate)) {
  1051. $this->input_not_pubdate = $dates[0];
  1052. }
  1053. }
  1054. }
  1055. return $input;
  1056. }
  1057. /**
  1058. * Parse the search string to find userdate keyword and the search related to it.
  1059. * The search is the first word following the keyword.
  1060. */
  1061. private function parseUserdateSearch(string $input): string {
  1062. if (preg_match_all('/\\buserdate:(?P<search>[^\\s]*)/', $input, $matches)) {
  1063. $input = str_replace($matches[0], '', $input);
  1064. $dates = self::removeEmptyValues($matches['search']);
  1065. if (!empty($dates[0])) {
  1066. [$this->min_userdate, $this->max_userdate] = parseDateInterval($dates[0]);
  1067. if (is_int($this->min_userdate) || is_int($this->max_userdate)) {
  1068. $this->input_userdate = $dates[0];
  1069. }
  1070. }
  1071. }
  1072. return $input;
  1073. }
  1074. private function parseNotUserdateSearch(string $input): string {
  1075. if (preg_match_all('/(?<=[\\s(]|^)[!-]userdate:(?P<search>[^\\s]*)/', $input, $matches)) {
  1076. $input = str_replace($matches[0], '', $input);
  1077. $dates = self::removeEmptyValues($matches['search']);
  1078. if (!empty($dates[0])) {
  1079. [$this->not_min_userdate, $this->not_max_userdate] = parseDateInterval($dates[0]);
  1080. if (is_int($this->not_min_userdate) || is_int($this->not_max_userdate)) {
  1081. $this->input_not_userdate = $dates[0];
  1082. }
  1083. }
  1084. }
  1085. return $input;
  1086. }
  1087. /**
  1088. * Parse the search string to find tags keyword (# followed by a word)
  1089. * and the search related to it.
  1090. * The search is the first word following the #.
  1091. */
  1092. private function parseTagsSearch(string $input): string {
  1093. if (preg_match_all('%#(?P<search>/.*?(?<!\\\\)/[im]*)%', $input, $matches)) {
  1094. $this->tags_regex = $matches['search'];
  1095. $input = str_replace($matches[0], '', $input);
  1096. }
  1097. if (preg_match_all('/#(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  1098. $this->tags = $matches['search'];
  1099. $input = str_replace($matches[0], '', $input);
  1100. }
  1101. if (preg_match_all('/#(?P<search>[^\\s]+)/', $input, $matches)) {
  1102. $this->tags = $matches['search'];
  1103. $input = str_replace($matches[0], '', $input);
  1104. }
  1105. $this->tags = self::removeEmptyValues($this->tags);
  1106. if (empty($this->tags)) {
  1107. $this->tags = null;
  1108. } else {
  1109. $this->tags = self::decodeSpaces($this->tags);
  1110. }
  1111. return $input;
  1112. }
  1113. private function parseNotTagsSearch(string $input): string {
  1114. if (preg_match_all('%(?<=[\\s(]|^)[!-]#(?P<search>/.*?(?<!\\\\)/[im]*)%', $input, $matches)) {
  1115. $this->not_tags_regex = $matches['search'];
  1116. $input = str_replace($matches[0], '', $input);
  1117. }
  1118. if (preg_match_all('/(?<=[\\s(]|^)[!-]#(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  1119. $this->not_tags = $matches['search'];
  1120. $input = str_replace($matches[0], '', $input);
  1121. }
  1122. if (preg_match_all('/(?<=[\\s(]|^)[!-]#(?P<search>[^\\s]+)/', $input, $matches)) {
  1123. $this->not_tags = $matches['search'];
  1124. $input = str_replace($matches[0], '', $input);
  1125. }
  1126. $this->not_tags = self::removeEmptyValues($this->not_tags);
  1127. if (empty($this->not_tags)) {
  1128. $this->not_tags = null;
  1129. } else {
  1130. $this->not_tags = self::decodeSpaces($this->not_tags);
  1131. }
  1132. return $input;
  1133. }
  1134. /**
  1135. * Parse the search string to find search values.
  1136. * Every word is a distinct search value using a delimiter.
  1137. * Supported delimiters are single quote (') and double quotes (") and regex (/).
  1138. */
  1139. private function parseQuotedSearch(string $input): string {
  1140. $input = self::cleanSearch($input);
  1141. if ($input === '') {
  1142. return '';
  1143. }
  1144. if (preg_match_all('#(?<=[\\s(]|^)(?<![!-\\\\])(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
  1145. $this->search_regex = $matches['search'];
  1146. //TODO: Replace all those str_replace with PREG_OFFSET_CAPTURE
  1147. $input = str_replace($matches[0], '', $input);
  1148. }
  1149. if (preg_match_all('/(?<=[\\s(]|^)(?<![!-\\\\])(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  1150. $this->search = $matches['search'];
  1151. //TODO: Replace all those str_replace with PREG_OFFSET_CAPTURE
  1152. $input = str_replace($matches[0], '', $input);
  1153. }
  1154. return $input;
  1155. }
  1156. /**
  1157. * Parse the search string to find search values.
  1158. * Every word is a distinct search value.
  1159. */
  1160. private function parseSearch(string $input): string {
  1161. $input = self::cleanSearch($input);
  1162. if ($input === '') {
  1163. return '';
  1164. }
  1165. if (is_array($this->search)) {
  1166. $this->search = array_merge($this->search, explode(' ', $input));
  1167. } else {
  1168. $this->search = explode(' ', $input);
  1169. }
  1170. return $input;
  1171. }
  1172. private function parseNotSearch(string $input): string {
  1173. $input = self::cleanSearch($input);
  1174. if ($input === '') {
  1175. return '';
  1176. }
  1177. if (preg_match_all('#(?<=[\\s(]|^)[!-](?P<search>(?<!\\\\)/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
  1178. $this->not_search_regex = $matches['search'];
  1179. $input = str_replace($matches[0], '', $input);
  1180. }
  1181. if (preg_match_all('/(?<=[\\s(]|^)[!-](?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
  1182. $this->not_search = $matches['search'];
  1183. $input = str_replace($matches[0], '', $input);
  1184. }
  1185. $input = self::cleanSearch($input);
  1186. if ($input === '') {
  1187. return '';
  1188. }
  1189. if (preg_match_all('/(?<=[\\s(]|^)[!-](?P<search>[^\\s]+)/', $input, $matches)) {
  1190. $this->not_search = array_merge(is_array($this->not_search) ? $this->not_search : [], $matches['search']);
  1191. $input = str_replace($matches[0], '', $input);
  1192. }
  1193. $this->not_search = self::removeEmptyValues($this->not_search);
  1194. return $input;
  1195. }
  1196. /**
  1197. * Remove all unnecessary spaces in the search
  1198. */
  1199. private static function cleanSearch(string $input): string {
  1200. $input = preg_replace('/\\s+/', ' ', $input);
  1201. if (!is_string($input)) {
  1202. return '';
  1203. }
  1204. return trim($input);
  1205. }
  1206. /** Remove escaping backslashes for parenthesis logic */
  1207. private static function unescape(string $input): string {
  1208. return str_replace(['\\(', '\\)'], ['(', ')'], $input);
  1209. }
  1210. }