]> BookStack Code Mirror - bookstack/blobdiff - app/Search/SearchOptions.php
Customization: Added parent tag classes
[bookstack] / app / Search / SearchOptions.php
index 0bf9c3116b67d91e092ee3fa82abda3e01441fa0..bf527d9c3058c1a87f988212da0d086c63fe66e6 100644 (file)
@@ -2,26 +2,39 @@
 
 namespace BookStack\Search;
 
+use BookStack\Search\Options\ExactSearchOption;
+use BookStack\Search\Options\FilterSearchOption;
+use BookStack\Search\Options\SearchOption;
+use BookStack\Search\Options\TagSearchOption;
+use BookStack\Search\Options\TermSearchOption;
 use Illuminate\Http\Request;
 
 class SearchOptions
 {
-    public array $searches = [];
-    public array $exacts = [];
-    public array $tags = [];
-    public array $filters = [];
+    /** @var SearchOptionSet<TermSearchOption> */
+    public SearchOptionSet $searches;
+    /** @var SearchOptionSet<ExactSearchOption> */
+    public SearchOptionSet $exacts;
+    /** @var SearchOptionSet<TagSearchOption> */
+    public SearchOptionSet $tags;
+    /** @var SearchOptionSet<FilterSearchOption> */
+    public SearchOptionSet $filters;
+
+    public function __construct()
+    {
+        $this->searches = new SearchOptionSet();
+        $this->exacts = new SearchOptionSet();
+        $this->tags = new SearchOptionSet();
+        $this->filters = new SearchOptionSet();
+    }
 
     /**
      * Create a new instance from a search string.
      */
     public static function fromString(string $search): self
     {
-        $decoded = static::decode($search);
-        $instance = new SearchOptions();
-        foreach ($decoded as $type => $value) {
-            $instance->$type = $value;
-        }
-
+        $instance = new self();
+        $instance->addOptionsFromString($search);
         return $instance;
     }
 
@@ -41,46 +54,64 @@ class SearchOptions
         }
 
         $instance = new SearchOptions();
-        $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
+        $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags', 'extras']);
 
         $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
-        $instance->searches = $parsedStandardTerms['terms'];
-        $instance->exacts = $parsedStandardTerms['exacts'];
-
-        array_push($instance->exacts, ...array_filter($inputs['exact'] ?? []));
-
-        $instance->tags = array_filter($inputs['tags'] ?? []);
+        $inputExacts = array_filter($inputs['exact'] ?? []);
+        $instance->searches = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['terms']), TermSearchOption::class);
+        $instance->exacts = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['exacts']), ExactSearchOption::class);
+        $instance->exacts = $instance->exacts->merge(SearchOptionSet::fromValueArray($inputExacts, ExactSearchOption::class));
+        $instance->tags = SearchOptionSet::fromValueArray(array_filter($inputs['tags'] ?? []), TagSearchOption::class);
 
+        $cleanedFilters = [];
         foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
             if (empty($filterVal)) {
                 continue;
             }
-            $instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
+            $cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal;
+            $cleanedFilters[] = new FilterSearchOption($cleanedFilterVal, $filterKey);
         }
 
         if (isset($inputs['types']) && count($inputs['types']) < 4) {
-            $instance->filters['type'] = implode('|', $inputs['types']);
+            $cleanedFilters[] = new FilterSearchOption(implode('|', $inputs['types']), 'type');
+        }
+
+        $instance->filters = new SearchOptionSet($cleanedFilters);
+
+        // Parse and merge in extras if provided
+        if (!empty($inputs['extras'])) {
+            $extras = static::fromString($inputs['extras']);
+            $instance->searches = $instance->searches->merge($extras->searches);
+            $instance->exacts = $instance->exacts->merge($extras->exacts);
+            $instance->tags = $instance->tags->merge($extras->tags);
+            $instance->filters = $instance->filters->merge($extras->filters);
         }
 
         return $instance;
     }
 
     /**
-     * Decode a search string into an array of terms.
+     * Decode a search string and add its contents to this instance.
      */
-    protected static function decode(string $searchString): array
+    protected function addOptionsFromString(string $searchString): void
     {
+        /** @var array<string, SearchOption[]> $terms */
         $terms = [
-            'searches' => [],
             'exacts'   => [],
             'tags'     => [],
             'filters'  => [],
         ];
 
         $patterns = [
-            'exacts'  => '/"(.*?)"/',
-            'tags'    => '/\[(.*?)\]/',
-            'filters' => '/\{(.*?)\}/',
+            'exacts'  => '/-?"((?:\\\\.|[^"\\\\])*)"/',
+            'tags'    => '/-?\[(.*?)\]/',
+            'filters' => '/-?\{(.*?)\}/',
+        ];
+
+        $constructors = [
+            'exacts'   => fn(string $value, bool $negated) => new ExactSearchOption($value, $negated),
+            'tags'     => fn(string $value, bool $negated) => new TagSearchOption($value, $negated),
+            'filters'  => fn(string $value, bool $negated) => FilterSearchOption::fromContentString($value, $negated),
         ];
 
         // Parse special terms
@@ -88,37 +119,69 @@ class SearchOptions
             $matches = [];
             preg_match_all($pattern, $searchString, $matches);
             if (count($matches) > 0) {
-                $terms[$termType] = $matches[1];
+                foreach ($matches[1] as $index => $value) {
+                    $negated = str_starts_with($matches[0][$index], '-');
+                    $terms[$termType][] = $constructors[$termType]($value, $negated);
+                }
                 $searchString = preg_replace($pattern, '', $searchString);
             }
         }
 
+        // Unescape exacts and backslash escapes
+        foreach ($terms['exacts'] as $exact) {
+            $exact->value = static::decodeEscapes($exact->value);
+        }
+
         // Parse standard terms
         $parsedStandardTerms = static::parseStandardTermString($searchString);
-        array_push($terms['searches'], ...$parsedStandardTerms['terms']);
-        array_push($terms['exacts'], ...$parsedStandardTerms['exacts']);
-
-        // Split filter values out
-        $splitFilters = [];
-        foreach ($terms['filters'] as $filter) {
-            $explodedFilter = explode(':', $filter, 2);
-            $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
+        $this->searches = $this->searches
+            ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'], TermSearchOption::class))
+            ->filterEmpty();
+        $this->exacts = $this->exacts
+            ->merge(new SearchOptionSet($terms['exacts']))
+            ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'], ExactSearchOption::class))
+            ->filterEmpty();
+
+        // Add tags & filters
+        $this->tags = $this->tags->merge(new SearchOptionSet($terms['tags']));
+        $this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
+    }
+
+    /**
+     * Decode backslash escaping within the input string.
+     */
+    protected static function decodeEscapes(string $input): string
+    {
+        $decoded = "";
+        $escaping = false;
+
+        foreach (str_split($input) as $char) {
+            if ($escaping) {
+                $decoded .= $char;
+                $escaping = false;
+            } else if ($char === '\\') {
+                $escaping = true;
+            } else {
+                $decoded .= $char;
+            }
         }
-        $terms['filters'] = $splitFilters;
 
-        return $terms;
+        return $decoded;
     }
 
     /**
      * Parse a standard search term string into individual search terms and
-     * extract any exact terms searches to be made.
+     * convert any required terms to exact matches. This is done since some
+     * characters will never be in the standard index, since we use them as
+     * delimiters, and therefore we convert a term to be exact if it
+     * contains one of those delimiter characters.
      *
      * @return array{terms: array<string>, exacts: array<string>}
      */
     protected static function parseStandardTermString(string $termString): array
     {
         $terms = explode(' ', $termString);
-        $indexDelimiters = SearchIndex::$delimiters;
+        $indexDelimiters = implode('', array_diff(str_split(SearchIndex::$delimiters), str_split(SearchIndex::$softDelimiters)));
         $parsed = [
             'terms'  => [],
             'exacts' => [],
@@ -129,32 +192,65 @@ class SearchOptions
                 continue;
             }
 
-            $parsedList = (strpbrk($searchTerm, $indexDelimiters) === false) ? 'terms' : 'exacts';
-            $parsed[$parsedList][] = $searchTerm;
+            $becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
+            $parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
         }
 
         return $parsed;
     }
 
+    /**
+     * Set the value of a specific filter in the search options.
+     */
+    public function setFilter(string $filterName, string $filterValue = ''): void
+    {
+        $this->filters = $this->filters->merge(
+            new SearchOptionSet([new FilterSearchOption($filterValue, $filterName)])
+        );
+    }
+
     /**
      * Encode this instance to a search string.
      */
     public function toString(): string
     {
-        $string = implode(' ', $this->searches ?? []);
+        $options = [
+            ...$this->searches->all(),
+            ...$this->exacts->all(),
+            ...$this->tags->all(),
+            ...$this->filters->all(),
+        ];
 
-        foreach ($this->exacts as $term) {
-            $string .= ' "' . $term . '"';
-        }
+        $parts = array_map(fn(SearchOption $o) => $o->toString(), $options);
 
-        foreach ($this->tags as $term) {
-            $string .= " [{$term}]";
-        }
+        return implode(' ', $parts);
+    }
 
-        foreach ($this->filters as $filterName => $filterVal) {
-            $string .= ' {' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
+    /**
+     * Get the search options that don't have UI controls provided for.
+     * Provided back as a key => value array with the keys being expected
+     * input names for a search form, and values being the option value.
+     */
+    public function getAdditionalOptionsString(): string
+    {
+        $options = [];
+
+        // Handle filters without UI support
+        $userFilters = ['updated_by', 'created_by', 'owned_by'];
+        $unsupportedFilters = ['is_template', 'sort_by'];
+        foreach ($this->filters->all() as $filter) {
+            if (in_array($filter->getKey(), $userFilters, true) && $filter->value !== null && $filter->value !== 'me') {
+                $options[] = $filter;
+            } else if (in_array($filter->getKey(), $unsupportedFilters, true)) {
+                $options[] = $filter;
+            }
         }
 
-        return $string;
+        // Negated items
+        array_push($options, ...$this->exacts->negated()->all());
+        array_push($options, ...$this->tags->negated()->all());
+        array_push($options, ...$this->filters->negated()->all());
+
+        return implode(' ', array_map(fn(SearchOption $o) => $o->toString(), $options));
     }
 }