aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/Controllers/indexController.php2
-rw-r--r--app/Models/BooleanSearch.php100
-rw-r--r--app/Models/Search.php289
-rw-r--r--app/layout/header.phtml2
4 files changed, 389 insertions, 4 deletions
diff --git a/app/Controllers/indexController.php b/app/Controllers/indexController.php
index fa46c3f3a..d914e4eef 100644
--- a/app/Controllers/indexController.php
+++ b/app/Controllers/indexController.php
@@ -104,7 +104,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
$title = FreshRSS_Context::$name;
- $search = Minz_Request::paramString('search');
+ $search = FreshRSS_Context::$search->__toString();
if ($search !== '') {
$title = '“' . $search . '”';
}
diff --git a/app/Models/BooleanSearch.php b/app/Models/BooleanSearch.php
index 720cbf78e..68885cde8 100644
--- a/app/Models/BooleanSearch.php
+++ b/app/Models/BooleanSearch.php
@@ -52,6 +52,12 @@ class FreshRSS_BooleanSearch implements \Stringable {
$this->parseParentheses($input, $level) || $this->parseOrSegments($input);
}
+ public function __clone() {
+ foreach ($this->searches as $key => $search) {
+ $this->searches[$key] = clone $search;
+ }
+ }
+
/**
* Parse the user queries (saved searches) by name and expand them in the input string.
*/
@@ -431,9 +437,101 @@ class FreshRSS_BooleanSearch implements \Stringable {
$this->searches[] = $search;
}
+ /**
+ * Modify the first compatible search of the Boolean expression, or add it at the beginning.
+ * Useful to modify some search parameters.
+ * @return FreshRSS_BooleanSearch a new instance, modified.
+ */
+ public function enforce(FreshRSS_Search $search): self {
+ $result = clone $this;
+ $result->raw_input = '';
+
+ if (count($result->searches) === 1 && $result->searches[0] instanceof FreshRSS_Search) {
+ $result->searches[0] = $result->searches[0]->enforce($search);
+ return $result;
+ }
+ if (count($result->searches) === 2) {
+ foreach ($result->searches as $booleanSearch) {
+ if (!($booleanSearch instanceof FreshRSS_BooleanSearch)) {
+ break;
+ }
+ if ($booleanSearch->operator() === 'AND') {
+ if (count($booleanSearch->searches) === 1 && $booleanSearch->searches[0] instanceof FreshRSS_Search &&
+ $booleanSearch->searches[0]->hasSameOperators($search)) {
+ $booleanSearch->searches[0] = $search;
+ return $result;
+ }
+ }
+ }
+ }
+
+ if (count($result->searches) > 1 || (count($result->searches) > 0 && $result->searches[0] instanceof FreshRSS_Search)) {
+ // Wrap the existing searches in a new BooleanSearch if needed
+ $wrap = new FreshRSS_BooleanSearch('');
+ foreach ($result->searches as $existingSearch) {
+ $wrap->add($existingSearch);
+ }
+ if (count($wrap->searches) > 0) {
+ $result->searches = [$wrap];
+ }
+ }
+ array_unshift($result->searches, $search);
+ return $result;
+ }
+
+ /**
+ * Remove the first compatible search of the Boolean expression, if any.
+ * Useful to modify some search parameters.
+ * @return FreshRSS_BooleanSearch a new instance, modified.
+ */
+ public function remove(FreshRSS_Search $search): self {
+ $result = clone $this;
+ $result->raw_input = '';
+
+ if (count($result->searches) === 1 && $result->searches[0] instanceof FreshRSS_Search) {
+ $result->searches[0] = $result->searches[0]->remove($search);
+ return $result;
+ }
+ if (count($result->searches) === 2) {
+ foreach ($result->searches as $booleanSearch) {
+ if (!($booleanSearch instanceof FreshRSS_BooleanSearch)) {
+ break;
+ }
+ if ($booleanSearch->operator() === 'AND') {
+ if (count($booleanSearch->searches) === 1 && $booleanSearch->searches[0] instanceof FreshRSS_Search &&
+ $booleanSearch->searches[0]->hasSameOperators($search)) {
+ array_shift($booleanSearch->searches);
+ return $result;
+ }
+ }
+ }
+ }
+ return $result;
+ }
+
#[\Override]
public function __toString(): string {
- return $this->getRawInput();
+ $result = '';
+ foreach ($this->searches as $search) {
+ $part = $search->__toString();
+ if ($part === '') {
+ continue;
+ }
+ $operator = $search instanceof FreshRSS_BooleanSearch ? $search->operator() : 'OR';
+
+ if ((str_contains($part, ' ') || str_starts_with($part, '-')) && (count($this->searches) > 1 || in_array($operator, ['OR NOT', 'AND NOT'], true))) {
+ $part = '(' . $part . ')';
+ }
+
+ $result .= match ($operator) {
+ 'OR' => $result === '' ? '' : ' OR ',
+ 'OR NOT' => $result === '' ? '-' : ' OR -',
+ 'AND NOT' => $result === '' ? '-' : ' -',
+ 'AND' => $result === '' ? '' : ' ',
+ default => throw new InvalidArgumentException('Invalid operator: ' . $operator),
+ } . $part;
+ }
+ return trim($result);
}
/** @return string Plain text search query. Must be XML-encoded or URL-encoded depending on the situation */
diff --git a/app/Models/Search.php b/app/Models/Search.php
index f459d07e3..752e28408 100644
--- a/app/Models/Search.php
+++ b/app/Models/Search.php
@@ -154,9 +154,296 @@ class FreshRSS_Search implements \Stringable {
$this->parseSearch($input);
}
+ private static function quote(string $s): string {
+ if (str_contains($s, ' ') || $s === '') {
+ return '"' . addcslashes($s, '\\"') . '"';
+ }
+ return $s;
+ }
+
+ private static function dateIntervalToString(?int $min, ?int $max): string {
+ if ($min === null && $max === null) {
+ return '';
+ }
+ $s = '';
+ if ($min !== null) {
+ $s .= date('Y-m-d\\TH:i:s', $min);
+ }
+ $s .= '/';
+ if ($max !== null) {
+ $s .= date('Y-m-d\\TH:i:s', $max);
+ }
+ return $s;
+ }
+
+ /**
+ * Return true if both searches have the same constraint parameters (even if the values differ), false otherwise.
+ */
+ public function hasSameOperators(FreshRSS_Search $search): bool {
+ $properties = array_keys(get_object_vars($this));
+ $properties = array_diff($properties, ['raw_input']); // raw_input is not a constraint parameter
+ foreach ($properties as $property) {
+ // @phpstan-ignore property.dynamicName, property.dynamicName
+ if (gettype($this->$property) !== gettype($search->$property)) {
+ if (str_contains($property, 'min_') || str_contains($property, 'max_')) {
+ // Process {min_*, max_*} pairs together (for dates)
+ $mate = str_contains($property, 'min_') ? str_replace('min_', 'max_', $property) : str_replace('max_', 'min_', $property);
+ // @phpstan-ignore property.dynamicName, property.dynamicName, property.dynamicName, property.dynamicName
+ if (($this->$property !== null || $this->$mate !== null) !== ($search->$property !== null || $search->$mate !== null)) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+ // @phpstan-ignore property.dynamicName, property.dynamicName
+ if (is_array($this->$property) && is_array($search->$property)) {
+ // @phpstan-ignore property.dynamicName, property.dynamicName
+ if (count($this->$property) !== count($search->$property)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Modifies this search by enforcing the constraint parameters of another search.
+ * @return FreshRSS_Search a new instance, modified.
+ */
+ public function enforce(FreshRSS_Search $search): self {
+ $result = clone $this;
+ $properties = array_keys(get_object_vars($result));
+ $properties = array_diff($properties, ['raw_input']); // raw_input is not a constraint parameter
+ $result->raw_input = '';
+ foreach ($properties as $property) {
+ // @phpstan-ignore property.dynamicName
+ if ($search->$property !== null) {
+ // @phpstan-ignore property.dynamicName, property.dynamicName
+ $result->$property = $search->$property;
+ if (str_contains($property, 'min_') || str_contains($property, 'max_')) {
+ // Process {min_*, max_*} pairs together (for dates)
+ $mate = str_contains($property, 'min_') ? str_replace('min_', 'max_', $property) : str_replace('max_', 'min_', $property);
+ // @phpstan-ignore property.dynamicName, property.dynamicName
+ $result->$mate = $search->$mate;
+ }
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * Modifies this search by removing the constraints given by another search.
+ * @return FreshRSS_Search a new instance, modified.
+ */
+ public function remove(FreshRSS_Search $search): self {
+ $result = clone $this;
+ $properties = array_keys(get_object_vars($result));
+ $properties = array_diff($properties, ['raw_input']); // raw_input is not a constraint parameter
+ $result->raw_input = '';
+ foreach ($properties as $property) {
+ // @phpstan-ignore property.dynamicName
+ if ($search->$property !== null) {
+ // @phpstan-ignore property.dynamicName
+ $result->$property = null;
+ if (str_contains($property, 'min_') || str_contains($property, 'max_')) {
+ // Process {min_*, max_*} pairs together (for dates)
+ $mate = str_contains($property, 'min_') ? str_replace('min_', 'max_', $property) : str_replace('max_', 'min_', $property);
+ // @phpstan-ignore property.dynamicName
+ $result->$mate = null;
+ }
+ }
+ }
+ return $result;
+ }
+
#[\Override]
public function __toString(): string {
- return $this->getRawInput();
+ $result = '';
+
+ if ($this->getEntryIds() !== null) {
+ $result .= ' e:' . implode(',', $this->getEntryIds());
+ }
+ if ($this->getFeedIds() !== null) {
+ $result .= ' f:' . implode(',', $this->getFeedIds());
+ }
+ if ($this->getCategoryIds() !== null) {
+ $result .= ' c:' . implode(',', $this->getCategoryIds());
+ }
+ if ($this->getLabelIds() !== null) {
+ foreach ($this->getLabelIds() as $ids) {
+ $result .= ' L:' . (is_array($ids) ? implode(',', $ids) : $ids);
+ }
+ }
+ if ($this->getLabelNames() !== null) {
+ foreach ($this->getLabelNames() as $names) {
+ $result .= ' labels:' . self::quote(implode(',', $names));
+ }
+ }
+
+ if ($this->getMinUserdate() !== null || $this->getMaxUserdate() !== null) {
+ $result .= ' userdate:' . self::dateIntervalToString($this->getMinUserdate(), $this->getMaxUserdate());
+ }
+ if ($this->getMinPubdate() !== null || $this->getMaxPubdate() !== null) {
+ $result .= ' pubdate:' . self::dateIntervalToString($this->getMinPubdate(), $this->getMaxPubdate());
+ }
+ if ($this->getMinDate() !== null || $this->getMaxDate() !== null) {
+ $result .= ' date:' . self::dateIntervalToString($this->getMinDate(), $this->getMaxDate());
+ }
+
+ if ($this->getIntitleRegex() !== null) {
+ foreach ($this->getIntitleRegex() as $s) {
+ $result .= ' intitle:' . $s;
+ }
+ }
+ if ($this->getIntitle() !== null) {
+ foreach ($this->getIntitle() as $s) {
+ $result .= ' intitle:' . self::quote($s);
+ }
+ }
+ if ($this->getIntextRegex() !== null) {
+ foreach ($this->getIntextRegex() as $s) {
+ $result .= ' intext:' . $s;
+ }
+ }
+ if ($this->getIntext() !== null) {
+ foreach ($this->getIntext() as $s) {
+ $result .= ' intext:' . self::quote($s);
+ }
+ }
+ if ($this->getAuthorRegex() !== null) {
+ foreach ($this->getAuthorRegex() as $s) {
+ $result .= ' author:' . $s;
+ }
+ }
+ if ($this->getAuthor() !== null) {
+ foreach ($this->getAuthor() as $s) {
+ $result .= ' author:' . self::quote($s);
+ }
+ }
+ if ($this->getInurlRegex() !== null) {
+ foreach ($this->getInurlRegex() as $s) {
+ $result .= ' inurl:' . $s;
+ }
+ }
+ if ($this->getInurl() !== null) {
+ foreach ($this->getInurl() as $s) {
+ $result .= ' inurl:' . self::quote($s);
+ }
+ }
+ if ($this->getTagsRegex() !== null) {
+ foreach ($this->getTagsRegex() as $s) {
+ $result .= ' #' . $s;
+ }
+ }
+ if ($this->getTags() !== null) {
+ foreach ($this->getTags() as $s) {
+ $result .= ' #' . self::quote($s);
+ }
+ }
+ if ($this->getSearchRegex() !== null) {
+ foreach ($this->getSearchRegex() as $s) {
+ $result .= ' ' . $s;
+ }
+ }
+ if ($this->getSearch() !== null) {
+ foreach ($this->getSearch() as $s) {
+ $result .= ' ' . self::quote($s);
+ }
+ }
+
+ if ($this->getNotEntryIds() !== null) {
+ $result .= ' -e:' . implode(',', $this->getNotEntryIds());
+ }
+ if ($this->getNotFeedIds() !== null) {
+ $result .= ' -f:' . implode(',', $this->getNotFeedIds());
+ }
+ if ($this->getNotCategoryIds() !== null) {
+ $result .= ' -c:' . implode(',', $this->getNotCategoryIds());
+ }
+ if ($this->getNotLabelIds() !== null) {
+ foreach ($this->getNotLabelIds() as $ids) {
+ $result .= ' -L:' . (is_array($ids) ? implode(',', $ids) : $ids);
+ }
+ }
+ if ($this->getNotLabelNames() !== null) {
+ foreach ($this->getNotLabelNames() as $names) {
+ $result .= ' -labels:' . self::quote(implode(',', $names));
+ }
+ }
+
+ if ($this->getNotMinUserdate() !== null || $this->getNotMaxUserdate() !== null) {
+ $result .= ' -userdate:' . self::dateIntervalToString($this->getNotMinUserdate(), $this->getNotMaxUserdate());
+ }
+ if ($this->getNotMinPubdate() !== null || $this->getNotMaxPubdate() !== null) {
+ $result .= ' -pubdate:' . self::dateIntervalToString($this->getNotMinPubdate(), $this->getNotMaxPubdate());
+ }
+ if ($this->getNotMinDate() !== null || $this->getNotMaxDate() !== null) {
+ $result .= ' -date:' . self::dateIntervalToString($this->getNotMinDate(), $this->getNotMaxDate());
+ }
+
+ if ($this->getNotIntitleRegex() !== null) {
+ foreach ($this->getNotIntitleRegex() as $s) {
+ $result .= ' -intitle:' . $s;
+ }
+ }
+ if ($this->getNotIntitle() !== null) {
+ foreach ($this->getNotIntitle() as $s) {
+ $result .= ' -intitle:' . self::quote($s);
+ }
+ }
+ if ($this->getNotIntextRegex() !== null) {
+ foreach ($this->getNotIntextRegex() as $s) {
+ $result .= ' -intext:' . $s;
+ }
+ }
+ if ($this->getNotIntext() !== null) {
+ foreach ($this->getNotIntext() as $s) {
+ $result .= ' -intext:' . self::quote($s);
+ }
+ }
+ if ($this->getNotAuthorRegex() !== null) {
+ foreach ($this->getNotAuthorRegex() as $s) {
+ $result .= ' -author:' . $s;
+ }
+ }
+ if ($this->getNotAuthor() !== null) {
+ foreach ($this->getNotAuthor() as $s) {
+ $result .= ' -author:' . self::quote($s);
+ }
+ }
+ if ($this->getNotInurlRegex() !== null) {
+ foreach ($this->getNotInurlRegex() as $s) {
+ $result .= ' -inurl:' . $s;
+ }
+ }
+ if ($this->getNotInurl() !== null) {
+ foreach ($this->getNotInurl() as $s) {
+ $result .= ' -inurl:' . self::quote($s);
+ }
+ }
+ if ($this->getNotTagsRegex() !== null) {
+ foreach ($this->getNotTagsRegex() as $s) {
+ $result .= ' -#' . $s;
+ }
+ }
+ if ($this->getNotTags() !== null) {
+ foreach ($this->getNotTags() as $s) {
+ $result .= ' -#' . self::quote($s);
+ }
+ }
+ if ($this->getNotSearchRegex() !== null) {
+ foreach ($this->getNotSearchRegex() as $s) {
+ $result .= ' -' . $s;
+ }
+ }
+ if ($this->getNotSearch() !== null) {
+ foreach ($this->getNotSearch() as $s) {
+ $result .= ' -' . self::quote($s);
+ }
+ }
+
+ return trim($result);
}
public function getRawInput(): string {
diff --git a/app/layout/header.phtml b/app/layout/header.phtml
index 2fd6bb548..5c6ea63a4 100644
--- a/app/layout/header.phtml
+++ b/app/layout/header.phtml
@@ -40,7 +40,7 @@
<?php } ?>
<div class="stick">
<input type="search" name="search" id="search"
- value="<?= Minz_Request::paramString('search') ?>"
+ value="<?= FreshRSS_Context::$search->__toString() ?>"
placeholder="<?= _t('gen.menu.search') ?>" />
<button class="btn" type="submit"><?= _i('search') ?></button>
</div>