From 01cb22af37535d9d12d76a56413fdb645568972a Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Mon, 27 Mar 2017 18:05:34 +0100
Subject: [PATCH] Added tag searches and advanced filters to new search

---
 app/Repos/EntityRepo.php       | 153 ---------------------------------
 app/Services/SearchService.php | 148 +++++++++++++++++++++++++++++--
 resources/views/base.blade.php |   2 +-
 routes/web.php                 |   2 +-
 4 files changed, 144 insertions(+), 161 deletions(-)

diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php
index 8e3f68859..b1b69814d 100644
--- a/app/Repos/EntityRepo.php
+++ b/app/Repos/EntityRepo.php
@@ -64,12 +64,6 @@ class EntityRepo
      */
     protected $searchService;
 
-    /**
-     * Acceptable operators to be used in a query
-     * @var array
-     */
-    protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
-
     /**
      * EntityRepo constructor.
      * @param Book $book
@@ -370,56 +364,6 @@ class EntityRepo
             ->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get();
     }
 
-    /**
-     * Search entities of a type via a given query.
-     * @param string $type
-     * @param string $term
-     * @param array $whereTerms
-     * @param int $count
-     * @param array $paginationAppends
-     * @return mixed
-     */
-    public function getBySearch($type, $term, $whereTerms = [], $count = 20, $paginationAppends = [])
-    {
-        $terms = $this->prepareSearchTerms($term);
-        $q = $this->permissionService->enforceEntityRestrictions($type, $this->getEntity($type)->fullTextSearchQuery($terms, $whereTerms));
-        $q = $this->addAdvancedSearchQueries($q, $term);
-        $entities = $q->paginate($count)->appends($paginationAppends);
-        $words = join('|', explode(' ', preg_quote(trim($term), '/')));
-
-        // Highlight page content
-        if ($type === 'page') {
-            //lookahead/behind assertions ensures cut between words
-            $s = '\s\x00-/:-@\[-`{-~'; //character set for start/end of words
-
-            foreach ($entities as $page) {
-                preg_match_all('#(?<=[' . $s . ']).{1,30}((' . $words . ').{1,30})+(?=[' . $s . '])#uis', $page->text, $matches, PREG_SET_ORDER);
-                //delimiter between occurrences
-                $results = [];
-                foreach ($matches as $line) {
-                    $results[] = htmlspecialchars($line[0], 0, 'UTF-8');
-                }
-                $matchLimit = 6;
-                if (count($results) > $matchLimit) $results = array_slice($results, 0, $matchLimit);
-                $result = join('... ', $results);
-
-                //highlight
-                $result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $result);
-                if (strlen($result) < 5) $result = $page->getExcerpt(80);
-
-                $page->searchSnippet = $result;
-            }
-            return $entities;
-        }
-
-        // Highlight chapter/book content
-        foreach ($entities as $entity) {
-            //highlight
-            $result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $entity->getExcerpt(100));
-            $entity->searchSnippet = $result;
-        }
-        return $entities;
-    }
 
     /**
      * Get the next sequential priority for a new child element in the given book.
@@ -501,104 +445,7 @@ class EntityRepo
         $this->permissionService->buildJointPermissionsForEntity($entity);
     }
 
-    /**
-     * Prepare a string of search terms by turning
-     * it into an array of terms.
-     * Keeps quoted terms together.
-     * @param $termString
-     * @return array
-     */
-    public function prepareSearchTerms($termString)
-    {
-        $termString = $this->cleanSearchTermString($termString);
-        preg_match_all('/(".*?")/', $termString, $matches);
-        $terms = [];
-        if (count($matches[1]) > 0) {
-            foreach ($matches[1] as $match) {
-                $terms[] = $match;
-            }
-            $termString = trim(preg_replace('/"(.*?)"/', '', $termString));
-        }
-        if (!empty($termString)) $terms = array_merge($terms, explode(' ', $termString));
-        return $terms;
-    }
 
-    /**
-     * Removes any special search notation that should not
-     * be used in a full-text search.
-     * @param $termString
-     * @return mixed
-     */
-    protected function cleanSearchTermString($termString)
-    {
-        // Strip tag searches
-        $termString = preg_replace('/\[.*?\]/', '', $termString);
-        // Reduced multiple spacing into single spacing
-        $termString = preg_replace("/\s{2,}/", " ", $termString);
-        return $termString;
-    }
-
-    /**
-     * Get the available query operators as a regex escaped list.
-     * @return mixed
-     */
-    protected function getRegexEscapedOperators()
-    {
-        $escapedOperators = [];
-        foreach ($this->queryOperators as $operator) {
-            $escapedOperators[] = preg_quote($operator);
-        }
-        return join('|', $escapedOperators);
-    }
-
-    /**
-     * Parses advanced search notations and adds them to the db query.
-     * @param $query
-     * @param $termString
-     * @return mixed
-     */
-    protected function addAdvancedSearchQueries($query, $termString)
-    {
-        $escapedOperators = $this->getRegexEscapedOperators();
-        // Look for tag searches
-        preg_match_all("/\[(.*?)((${escapedOperators})(.*?))?\]/", $termString, $tags);
-        if (count($tags[0]) > 0) {
-            $this->applyTagSearches($query, $tags);
-        }
-
-        return $query;
-    }
-
-    /**
-     * Apply extracted tag search terms onto a entity query.
-     * @param $query
-     * @param $tags
-     * @return mixed
-     */
-    protected function applyTagSearches($query, $tags) {
-        $query->where(function($query) use ($tags) {
-            foreach ($tags[1] as $index => $tagName) {
-                $query->whereHas('tags', function($query) use ($tags, $index, $tagName) {
-                    $tagOperator = $tags[3][$index];
-                    $tagValue = $tags[4][$index];
-                    if (!empty($tagOperator) && !empty($tagValue) && in_array($tagOperator, $this->queryOperators)) {
-                        if (is_numeric($tagValue) && $tagOperator !== 'like') {
-                            // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
-                            // search the value as a string which prevents being able to do number-based operations
-                            // on the tag values. We ensure it has a numeric value and then cast it just to be sure.
-                            $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
-                            $query->where('name', '=', $tagName)->whereRaw("value ${tagOperator} ${tagValue}");
-                        } else {
-                            $query->where('name', '=', $tagName)->where('value', $tagOperator, $tagValue);
-                        }
-                    } else {
-                        $query->where('name', '=', $tagName);
-                    }
-                });
-            }
-        });
-        return $query;
-    }
 
     /**
      * Create a new entity from request input.
diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php
index 2df02bc3e..ec505af00 100644
--- a/app/Services/SearchService.php
+++ b/app/Services/SearchService.php
@@ -12,7 +12,6 @@ use Illuminate\Support\Collection;
 
 class SearchService
 {
-
     protected $searchTerm;
     protected $book;
     protected $chapter;
@@ -21,6 +20,12 @@ class SearchService
     protected $permissionService;
     protected $entities;
 
+    /**
+     * Acceptable operators to be used in a query
+     * @var array
+     */
+    protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
+
     /**
      * SearchService constructor.
      * @param SearchTerm $searchTerm
@@ -55,11 +60,7 @@ class SearchService
      */
     public function searchEntities($searchString, $entityType = 'all', $page = 0, $count = 20)
     {
-        // TODO - Add Tag Searches
-        // TODO - Add advanced custom column searches
         // TODO - Check drafts don't show up in results
-        // TODO - Move search all page to just /search?term=cat
-
        if ($entityType !== 'all') return $this->searchEntityTable($searchString, $entityType, $page, $count);
 
        $bookSearch = $this->searchEntityTable($searchString, 'book', $page, $count);
@@ -109,6 +110,19 @@ class SearchService
             });
         }
 
+        // Handle tag searches
+        foreach ($searchTerms['tags'] as $inputTerm) {
+            $this->applyTagSearch($entitySelect, $inputTerm);
+        }
+
+        // Handle filters
+        foreach ($searchTerms['filters'] as $filterTerm) {
+            $splitTerm = explode(':', $filterTerm);
+            $functionName = camel_case('filter_' . $splitTerm[0]);
+            $param = count($splitTerm) > 1 ? $splitTerm[1] : '';
+            if (method_exists($this, $functionName)) $this->$functionName($entitySelect, $entity, $param);
+        }
+
         $entitySelect->skip($page * $count)->take($count);
         $query = $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, 'view');
         return $query->get();
@@ -120,7 +134,7 @@ class SearchService
      * @param $searchString
      * @return array
      */
-    public function parseSearchString($searchString)
+    protected function parseSearchString($searchString)
     {
         $terms = [
             'search' => [],
@@ -151,6 +165,50 @@ class SearchService
         return $terms;
     }
 
+    /**
+     * Get the available query operators as a regex escaped list.
+     * @return mixed
+     */
+    protected function getRegexEscapedOperators()
+    {
+        $escapedOperators = [];
+        foreach ($this->queryOperators as $operator) {
+            $escapedOperators[] = preg_quote($operator);
+        }
+        return join('|', $escapedOperators);
+    }
+
+    /**
+     * Apply a tag search term onto a entity query.
+     * @param \Illuminate\Database\Eloquent\Builder $query
+     * @param string $tagTerm
+     * @return mixed
+     */
+    protected function applyTagSearch(\Illuminate\Database\Eloquent\Builder $query, $tagTerm) {
+        preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
+        $query->whereHas('tags', function(\Illuminate\Database\Eloquent\Builder $query) use ($tagSplit) {
+            $tagName = $tagSplit[1];
+            $tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
+            $tagValue = count($tagSplit) > 3 ? $tagSplit[4] : '';
+            $validOperator = in_array($tagOperator, $this->queryOperators);
+            if (!empty($tagOperator) && !empty($tagValue) && $validOperator) {
+                if (!empty($tagName)) $query->where('name', '=', $tagName);
+                if (is_numeric($tagValue) && $tagOperator !== 'like') {
+                    // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
+                    // search the value as a string which prevents being able to do number-based operations
+                    // on the tag values. We ensure it has a numeric value and then cast it just to be sure.
+                    $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
+                    $query->whereRaw("value ${tagOperator} ${tagValue}");
+                } else {
+                    $query->where('value', $tagOperator, $tagValue);
+                }
+            } else {
+                $query->where('name', '=', $tagName);
+            }
+        });
+        return $query;
+    }
+
     /**
      * Get an entity instance via type.
      * @param $type
@@ -258,4 +316,82 @@ class SearchService
         return $terms;
     }
 
+
+
+
+    /**
+     * Custom entity search filters
+     */
+
+    protected function filterUpdatedAfter(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        try { $date = date_create($input);
+        } catch (\Exception $e) {return;}
+        $query->where('updated_at', '>=', $date);
+    }
+
+    protected function filterUpdatedBefore(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        try { $date = date_create($input);
+        } catch (\Exception $e) {return;}
+        $query->where('updated_at', '<', $date);
+    }
+
+    protected function filterCreatedAfter(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        try { $date = date_create($input);
+        } catch (\Exception $e) {return;}
+        $query->where('created_at', '>=', $date);
+    }
+
+    protected function filterCreatedBefore(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        try { $date = date_create($input);
+        } catch (\Exception $e) {return;}
+        $query->where('created_at', '<', $date);
+    }
+
+    protected function filterCreatedBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        if (!is_numeric($input)) return;
+        $query->where('created_by', '=', $input);
+    }
+
+    protected function filterUpdatedBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        if (!is_numeric($input)) return;
+        $query->where('updated_by', '=', $input);
+    }
+
+    protected function filterInName(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        $query->where('name', 'like', '%' .$input. '%');
+    }
+
+    protected function filterInTitle(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) {$this->filterInName($query, $model, $input);}
+
+    protected function filterInBody(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        $query->where($model->textField, 'like', '%' .$input. '%');
+    }
+
+    protected function filterIsRestricted(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        $query->where('restricted', '=', true);
+    }
+
+    protected function filterViewedByMe(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        $query->whereHas('views', function($query) {
+            $query->where('user_id', '=', user()->id);
+        });
+    }
+
+    protected function filterNotViewedByMe(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        $query->whereDoesntHave('views', function($query) {
+            $query->where('user_id', '=', user()->id);
+        });
+    }
+
 }
\ No newline at end of file
diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php
index 4287014c2..95a9d72b0 100644
--- a/resources/views/base.blade.php
+++ b/resources/views/base.blade.php
@@ -47,7 +47,7 @@
                     </a>
                 </div>
                 <div class="col-lg-4 col-sm-3 text-center">
-                    <form action="{{ baseUrl('/search/all') }}" method="GET" class="search-box">
+                    <form action="{{ baseUrl('/search') }}" method="GET" class="search-box">
                         <input id="header-search-box-input" type="text" name="term" tabindex="2" value="{{ isset($searchTerm) ? $searchTerm : '' }}">
                         <button id="header-search-box-button" type="submit" class="text-button"><i class="zmdi zmdi-search"></i></button>
                     </form>
diff --git a/routes/web.php b/routes/web.php
index 8259a633b..f5ee3f827 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -123,7 +123,7 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/link/{id}', 'PageController@redirectFromLink');
 
     // Search
-    Route::get('/search/all', 'SearchController@searchAll');
+    Route::get('/search', 'SearchController@searchAll');
     Route::get('/search/pages', 'SearchController@searchPages');
     Route::get('/search/books', 'SearchController@searchBooks');
     Route::get('/search/chapters', 'SearchController@searchChapters');