]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #360 from Abijeet/spellcheck-fix
authorDan Brown <redacted>
Sun, 16 Apr 2017 14:02:20 +0000 (15:02 +0100)
committerGitHub <redacted>
Sun, 16 Apr 2017 14:02:20 +0000 (15:02 +0100)
Fixes #354, Adds the spellchecker option

46 files changed:
app/Book.php
app/Chapter.php
app/Console/Commands/RegeneratePermissions.php
app/Console/Commands/RegenerateSearch.php [new file with mode: 0644]
app/Console/Kernel.php
app/Entity.php
app/Http/Controllers/SearchController.php
app/Page.php
app/Repos/EntityRepo.php
app/SearchTerm.php [new file with mode: 0644]
app/Services/PermissionService.php
app/Services/SearchService.php [new file with mode: 0644]
database/migrations/2015_08_31_175240_add_search_indexes.php
database/migrations/2015_12_05_145049_fulltext_weighting.php
database/migrations/2017_03_19_091553_create_search_index_table.php [new file with mode: 0644]
database/seeds/DummyContentSeeder.php
gulpfile.js
package.json
resources/assets/js/controllers.js
resources/assets/js/directives.js
resources/assets/js/global.js
resources/assets/js/pages/page-form.js
resources/assets/js/pages/page-show.js
resources/assets/js/translations.js
resources/assets/js/vues/entity-search.js [new file with mode: 0644]
resources/assets/js/vues/search.js [new file with mode: 0644]
resources/assets/js/vues/vues.js [new file with mode: 0644]
resources/assets/sass/_animations.scss
resources/assets/sass/_forms.scss
resources/assets/sass/_lists.scss
resources/assets/sass/styles.scss
resources/lang/de/entities.php
resources/lang/en/common.php
resources/lang/en/entities.php
resources/lang/es/entities.php
resources/lang/fr/entities.php
resources/lang/nl/entities.php
resources/lang/pt_BR/entities.php
resources/views/base.blade.php
resources/views/books/show.blade.php
resources/views/chapters/show.blade.php
resources/views/pages/sidebar-tree-list.blade.php
resources/views/search/all.blade.php
routes/web.php
tests/Entity/EntitySearchTest.php
tests/TestCase.php

index 91f74ca6428f89ae5c54c675ac5b56ed62a532e0..06c00945d43c4dbdea946c21769ffef7a626888f 100644 (file)
@@ -56,4 +56,13 @@ class Book extends Entity
         return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description;
     }
 
+    /**
+     * Return a generalised, common raw query that can be 'unioned' across entities.
+     * @return string
+     */
+    public function entityRawQuery()
+    {
+        return "'BookStack\\\\Book' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
+    }
+
 }
index dc23f5ebdbd015fbab800084baf3066ab66d1a59..b08cb913a425808fdd25ba5d7c16b4711f79d906 100644 (file)
@@ -51,4 +51,13 @@ class Chapter extends Entity
         return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description;
     }
 
+    /**
+     * Return a generalised, common raw query that can be 'unioned' across entities.
+     * @return string
+     */
+    public function entityRawQuery()
+    {
+        return "'BookStack\\\\Chapter' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, '' as html, book_id, priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
+    }
+
 }
index 966ee4a820841429148a19c3724003492909742b..1dc25f9aa6e996f4a8db898b513dfe08207f83a3 100644 (file)
@@ -12,7 +12,7 @@ class RegeneratePermissions extends Command
      *
      * @var string
      */
-    protected $signature = 'bookstack:regenerate-permissions';
+    protected $signature = 'bookstack:regenerate-permissions {--database= : The database connection to use.}';
 
     /**
      * The console command description.
@@ -46,7 +46,14 @@ class RegeneratePermissions extends Command
      */
     public function handle()
     {
+        $connection = \DB::getDefaultConnection();
+        if ($this->option('database') !== null) {
+            \DB::setDefaultConnection($this->option('database'));
+        }
+
         $this->permissionService->buildJointPermissions();
+
+        \DB::setDefaultConnection($connection);
         $this->comment('Permissions regenerated');
     }
 }
diff --git a/app/Console/Commands/RegenerateSearch.php b/app/Console/Commands/RegenerateSearch.php
new file mode 100644 (file)
index 0000000..35ecd46
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+
+namespace BookStack\Console\Commands;
+
+use BookStack\Services\SearchService;
+use Illuminate\Console\Command;
+
+class RegenerateSearch extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'bookstack:regenerate-search {--database= : The database connection to use.}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    protected $searchService;
+
+    /**
+     * Create a new command instance.
+     *
+     * @param SearchService $searchService
+     */
+    public function __construct(SearchService $searchService)
+    {
+        parent::__construct();
+        $this->searchService = $searchService;
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+        $connection = \DB::getDefaultConnection();
+        if ($this->option('database') !== null) {
+            \DB::setDefaultConnection($this->option('database'));
+        }
+
+        $this->searchService->indexAllEntities();
+        \DB::setDefaultConnection($connection);
+        $this->comment('Search index regenerated');
+    }
+}
index 0112e72caa9c825a95e11d8eb167ad88533537d3..4fa0b3c80c682544a4a59eac88211d5ec23c40b2 100644 (file)
@@ -1,6 +1,4 @@
-<?php
-
-namespace BookStack\Console;
+<?php namespace BookStack\Console;
 
 use Illuminate\Console\Scheduling\Schedule;
 use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@@ -13,10 +11,11 @@ class Kernel extends ConsoleKernel
      * @var array
      */
     protected $commands = [
-        \BookStack\Console\Commands\ClearViews::class,
-        \BookStack\Console\Commands\ClearActivity::class,
-        \BookStack\Console\Commands\ClearRevisions::class,
-        \BookStack\Console\Commands\RegeneratePermissions::class,
+        Commands\ClearViews::class,
+        Commands\ClearActivity::class,
+        Commands\ClearRevisions::class,
+        Commands\RegeneratePermissions::class,
+        Commands\RegenerateSearch::class
     ];
 
     /**
index e8deddf0a56f6f861e31ee83523ccac9c47c1bdc..6aeb66481dc436ca4654d8acd5c1ed3603d6665e 100644 (file)
@@ -4,7 +4,7 @@
 class Entity extends Ownable
 {
 
-    protected $fieldsToSearch = ['name', 'description'];
+    public $textField = 'description';
 
     /**
      * Compares this entity to another given entity.
@@ -65,6 +65,15 @@ class Entity extends Ownable
         return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
     }
 
+    /**
+     * Get the related search terms.
+     * @return \Illuminate\Database\Eloquent\Relations\MorphMany
+     */
+    public function searchTerms()
+    {
+        return $this->morphMany(SearchTerm::class, 'entity');
+    }
+
     /**
      * Get this entities restrictions.
      */
@@ -153,67 +162,19 @@ class Entity extends Ownable
     }
 
     /**
-     * Perform a full-text search on this entity.
-     * @param string[] $fieldsToSearch
-     * @param string[] $terms
-     * @param string[] array $wheres
+     * Get the body text of this entity.
      * @return mixed
      */
-    public function fullTextSearchQuery($terms, $wheres = [])
+    public function getText()
     {
-        $exactTerms = [];
-        $fuzzyTerms = [];
-        $search = static::newQuery();
-
-        foreach ($terms as $key => $term) {
-            $term = htmlentities($term, ENT_QUOTES);
-            $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
-            if (preg_match('/&quot;.*?&quot;/', $term) || is_numeric($term)) {
-                $term = str_replace('&quot;', '', $term);
-                $exactTerms[] = '%' . $term . '%';
-            } else {
-                $term = '' . $term . '*';
-                if ($term !== '*') $fuzzyTerms[] = $term;
-            }
-        }
-
-        $isFuzzy = count($exactTerms) === 0 && count($fuzzyTerms) > 0;
-
-
-        // Perform fulltext search if relevant terms exist.
-        if ($isFuzzy) {
-            $termString = implode(' ', $fuzzyTerms);
-            $fields = implode(',', $this->fieldsToSearch);
-            $search = $search->selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]);
-            $search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]);
-        }
-
-        // Ensure at least one exact term matches if in search
-        if (count($exactTerms) > 0) {
-            $search = $search->where(function ($query) use ($exactTerms) {
-                foreach ($exactTerms as $exactTerm) {
-                    foreach ($this->fieldsToSearch as $field) {
-                        $query->orWhere($field, 'like', $exactTerm);
-                    }
-                }
-            });
-        }
-
-        $orderBy = $isFuzzy ? 'title_relevance' : 'updated_at';
-
-        // Add additional where terms
-        foreach ($wheres as $whereTerm) {
-            $search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]);
-        }
+        return $this->{$this->textField};
+    }
 
-        // Load in relations
-        if ($this->isA('page')) {
-            $search = $search->with('book', 'chapter', 'createdBy', 'updatedBy');
-        } else if ($this->isA('chapter')) {
-            $search = $search->with('book');
-        }
+    /**
+     * Return a generalised, common raw query that can be 'unioned' across entities.
+     * @return string
+     */
+    public function entityRawQuery(){return '';}
 
-        return $search->orderBy($orderBy, 'desc');
-    }
 
 }
index 37aaccece64bbc5514c8ac8d55487ed16187ed2b..bf8165afe6f45db2d8db507249f4fee3cb58f425 100644 (file)
@@ -1,6 +1,7 @@
 <?php namespace BookStack\Http\Controllers;
 
 use BookStack\Repos\EntityRepo;
+use BookStack\Services\SearchService;
 use BookStack\Services\ViewService;
 use Illuminate\Http\Request;
 
@@ -8,16 +9,19 @@ class SearchController extends Controller
 {
     protected $entityRepo;
     protected $viewService;
+    protected $searchService;
 
     /**
      * SearchController constructor.
      * @param EntityRepo $entityRepo
      * @param ViewService $viewService
+     * @param SearchService $searchService
      */
-    public function __construct(EntityRepo $entityRepo, ViewService $viewService)
+    public function __construct(EntityRepo $entityRepo, ViewService $viewService, SearchService $searchService)
     {
         $this->entityRepo = $entityRepo;
         $this->viewService = $viewService;
+        $this->searchService = $searchService;
         parent::__construct();
     }
 
@@ -27,105 +31,55 @@ class SearchController extends Controller
      * @return \Illuminate\View\View
      * @internal param string $searchTerm
      */
-    public function searchAll(Request $request)
+    public function search(Request $request)
     {
-        if (!$request->has('term')) {
-            return redirect()->back();
-        }
         $searchTerm = $request->get('term');
-        $paginationAppends = $request->only('term');
-        $pages = $this->entityRepo->getBySearch('page', $searchTerm, [], 20, $paginationAppends);
-        $books = $this->entityRepo->getBySearch('book', $searchTerm, [], 10, $paginationAppends);
-        $chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, [], 10, $paginationAppends);
         $this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm]));
-        return view('search/all', [
-            'pages'      => $pages,
-            'books'      => $books,
-            'chapters'   => $chapters,
-            'searchTerm' => $searchTerm
-        ]);
-    }
 
-    /**
-     * Search only the pages in the system.
-     * @param Request $request
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View
-     */
-    public function searchPages(Request $request)
-    {
-        if (!$request->has('term')) return redirect()->back();
+        $page = $request->has('page') && is_int(intval($request->get('page'))) ? intval($request->get('page')) : 1;
+        $nextPageLink = baseUrl('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1));
 
-        $searchTerm = $request->get('term');
-        $paginationAppends = $request->only('term');
-        $pages = $this->entityRepo->getBySearch('page', $searchTerm, [], 20, $paginationAppends);
-        $this->setPageTitle(trans('entities.search_page_for_term', ['term' => $searchTerm]));
-        return view('search/entity-search-list', [
-            'entities'   => $pages,
-            'title'      => trans('entities.search_results_page'),
-            'searchTerm' => $searchTerm
-        ]);
-    }
-
-    /**
-     * Search only the chapters in the system.
-     * @param Request $request
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View
-     */
-    public function searchChapters(Request $request)
-    {
-        if (!$request->has('term')) return redirect()->back();
+        $results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20);
+        $hasNextPage = $this->searchService->searchEntities($searchTerm, 'all', $page+1, 20)['count'] > 0;
 
-        $searchTerm = $request->get('term');
-        $paginationAppends = $request->only('term');
-        $chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, [], 20, $paginationAppends);
-        $this->setPageTitle(trans('entities.search_chapter_for_term', ['term' => $searchTerm]));
-        return view('search/entity-search-list', [
-            'entities'   => $chapters,
-            'title'      => trans('entities.search_results_chapter'),
-            'searchTerm' => $searchTerm
+        return view('search/all', [
+            'entities'   => $results['results'],
+            'totalResults' => $results['total'],
+            'searchTerm' => $searchTerm,
+            'hasNextPage' => $hasNextPage,
+            'nextPageLink' => $nextPageLink
         ]);
     }
 
+
     /**
-     * Search only the books in the system.
+     * Searches all entities within a book.
      * @param Request $request
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View
+     * @param integer $bookId
+     * @return \Illuminate\View\View
+     * @internal param string $searchTerm
      */
-    public function searchBooks(Request $request)
+    public function searchBook(Request $request, $bookId)
     {
-        if (!$request->has('term')) return redirect()->back();
-
-        $searchTerm = $request->get('term');
-        $paginationAppends = $request->only('term');
-        $books = $this->entityRepo->getBySearch('book', $searchTerm, [], 20, $paginationAppends);
-        $this->setPageTitle(trans('entities.search_book_for_term', ['term' => $searchTerm]));
-        return view('search/entity-search-list', [
-            'entities'   => $books,
-            'title'      => trans('entities.search_results_book'),
-            'searchTerm' => $searchTerm
-        ]);
+        $term = $request->get('term', '');
+        $results = $this->searchService->searchBook($bookId, $term);
+        return view('partials/entity-list', ['entities' => $results]);
     }
 
     /**
-     * Searches all entities within a book.
+     * Searches all entities within a chapter.
      * @param Request $request
-     * @param integer $bookId
+     * @param integer $chapterId
      * @return \Illuminate\View\View
      * @internal param string $searchTerm
      */
-    public function searchBook(Request $request, $bookId)
+    public function searchChapter(Request $request, $chapterId)
     {
-        if (!$request->has('term')) {
-            return redirect()->back();
-        }
-        $searchTerm = $request->get('term');
-        $searchWhereTerms = [['book_id', '=', $bookId]];
-        $pages = $this->entityRepo->getBySearch('page', $searchTerm, $searchWhereTerms);
-        $chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, $searchWhereTerms);
-        return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
+        $term = $request->get('term', '');
+        $results = $this->searchService->searchChapter($chapterId, $term);
+        return view('partials/entity-list', ['entities' => $results]);
     }
 
-
     /**
      * Search for a list of entities and return a partial HTML response of matching entities.
      * Returns the most popular entities if no search is provided.
@@ -134,18 +88,13 @@ class SearchController extends Controller
      */
     public function searchEntitiesAjax(Request $request)
     {
-        $entities = collect();
         $entityTypes = $request->has('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']);
         $searchTerm = ($request->has('term') && trim($request->get('term')) !== '') ? $request->get('term') : false;
 
         // Search for entities otherwise show most popular
         if ($searchTerm !== false) {
-            foreach (['page', 'chapter', 'book'] as $entityType) {
-                if ($entityTypes->contains($entityType)) {
-                    $entities = $entities->merge($this->entityRepo->getBySearch($entityType, $searchTerm)->items());
-                }
-            }
-            $entities = $entities->sortByDesc('title_relevance');
+            $searchTerm .= ' {type:'. implode('|', $entityTypes->toArray()) .'}';
+            $entities = $this->searchService->searchEntities($searchTerm)['results'];
         } else {
             $entityNames = $entityTypes->map(function ($type) {
                 return 'BookStack\\' . ucfirst($type);
index b24e7778aead87706b0b736d8ddaa625be5812ec..c9823e7e4ccfcb65dce71328f28fa0f4f04d40a3 100644 (file)
@@ -8,8 +8,7 @@ class Page extends Entity
     protected $simpleAttributes = ['name', 'id', 'slug'];
 
     protected $with = ['book'];
-
-    protected $fieldsToSearch = ['name', 'text'];
+    public $textField = 'text';
 
     /**
      * Converts this page into a simplified array.
@@ -96,4 +95,14 @@ class Page extends Entity
         return mb_convert_encoding($text, 'UTF-8');
     }
 
+    /**
+     * Return a generalised, common raw query that can be 'unioned' across entities.
+     * @param bool $withContent
+     * @return string
+     */
+    public function entityRawQuery($withContent = false)
+    {   $htmlQuery = $withContent ? 'html' : "'' as html";
+        return "'BookStack\\\\Page' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, {$htmlQuery}, book_id, priority, chapter_id, draft, created_by, updated_by, updated_at, created_at";
+    }
+
 }
index 8f4b533ff29fc613bc1d2cd530839729550f87c1..449e3aa7d91c10171c7825a432875c82c44bcd42 100644 (file)
@@ -8,6 +8,7 @@ use BookStack\Page;
 use BookStack\PageRevision;
 use BookStack\Services\AttachmentService;
 use BookStack\Services\PermissionService;
+use BookStack\Services\SearchService;
 use BookStack\Services\ViewService;
 use Carbon\Carbon;
 use DOMDocument;
@@ -59,13 +60,12 @@ class EntityRepo
     protected $tagRepo;
 
     /**
-     * Acceptable operators to be used in a query
-     * @var array
+     * @var SearchService
      */
-    protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
+    protected $searchService;
 
     /**
-     * EntityService constructor.
+     * EntityRepo constructor.
      * @param Book $book
      * @param Chapter $chapter
      * @param Page $page
@@ -73,10 +73,12 @@ class EntityRepo
      * @param ViewService $viewService
      * @param PermissionService $permissionService
      * @param TagRepo $tagRepo
+     * @param SearchService $searchService
      */
     public function __construct(
         Book $book, Chapter $chapter, Page $page, PageRevision $pageRevision,
-        ViewService $viewService, PermissionService $permissionService, TagRepo $tagRepo
+        ViewService $viewService, PermissionService $permissionService,
+        TagRepo $tagRepo, SearchService $searchService
     )
     {
         $this->book = $book;
@@ -91,6 +93,7 @@ class EntityRepo
         $this->viewService = $viewService;
         $this->permissionService = $permissionService;
         $this->tagRepo = $tagRepo;
+        $this->searchService = $searchService;
     }
 
     /**
@@ -216,6 +219,7 @@ class EntityRepo
      * @param int $count
      * @param int $page
      * @param bool|callable $additionalQuery
+     * @return Collection
      */
     public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false)
     {
@@ -234,6 +238,7 @@ class EntityRepo
      * @param int $count
      * @param int $page
      * @param bool|callable $additionalQuery
+     * @return Collection
      */
     public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false)
     {
@@ -327,7 +332,7 @@ class EntityRepo
             if ($rawEntity->entity_type === 'BookStack\\Page') {
                 $entities[$index] = $this->page->newFromBuilder($rawEntity);
                 if ($renderPages) {
-                    $entities[$index]->html = $rawEntity->description;
+                    $entities[$index]->html = $rawEntity->html;
                     $entities[$index]->html = $this->renderPage($entities[$index]);
                 };
             } else if ($rawEntity->entity_type === 'BookStack\\Chapter') {
@@ -354,6 +359,7 @@ class EntityRepo
      * Get the child items for a chapter sorted by priority but
      * with draft items floated to the top.
      * @param Chapter $chapter
+     * @return \Illuminate\Database\Eloquent\Collection|static[]
      */
     public function getChapterChildren(Chapter $chapter)
     {
@@ -361,56 +367,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.
@@ -492,104 +448,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.
@@ -608,12 +467,13 @@ class EntityRepo
         $entity->updated_by = user()->id;
         $isChapter ? $book->chapters()->save($entity) : $entity->save();
         $this->permissionService->buildJointPermissionsForEntity($entity);
+        $this->searchService->indexEntity($entity);
         return $entity;
     }
 
     /**
      * Update entity details from request input.
-     * Use for books and chapters
+     * Used for books and chapters
      * @param string $type
      * @param Entity $entityModel
      * @param array $input
@@ -628,6 +488,7 @@ class EntityRepo
         $entityModel->updated_by = user()->id;
         $entityModel->save();
         $this->permissionService->buildJointPermissionsForEntity($entityModel);
+        $this->searchService->indexEntity($entityModel);
         return $entityModel;
     }
 
@@ -711,7 +572,7 @@ class EntityRepo
 
         $draftPage->save();
         $this->savePageRevision($draftPage, trans('entities.pages_initial_revision'));
-
+        $this->searchService->indexEntity($draftPage);
         return $draftPage;
     }
 
@@ -961,6 +822,8 @@ class EntityRepo
             $this->savePageRevision($page, $input['summary']);
         }
 
+        $this->searchService->indexEntity($page);
+
         return $page;
     }
 
@@ -1064,6 +927,7 @@ class EntityRepo
         $page->text = strip_tags($page->html);
         $page->updated_by = user()->id;
         $page->save();
+        $this->searchService->indexEntity($page);
         return $page;
     }
 
@@ -1156,6 +1020,7 @@ class EntityRepo
         $book->views()->delete();
         $book->permissions()->delete();
         $this->permissionService->deleteJointPermissionsForEntity($book);
+        $this->searchService->deleteEntityTerms($book);
         $book->delete();
     }
 
@@ -1175,6 +1040,7 @@ class EntityRepo
         $chapter->views()->delete();
         $chapter->permissions()->delete();
         $this->permissionService->deleteJointPermissionsForEntity($chapter);
+        $this->searchService->deleteEntityTerms($chapter);
         $chapter->delete();
     }
 
@@ -1190,6 +1056,7 @@ class EntityRepo
         $page->revisions()->delete();
         $page->permissions()->delete();
         $this->permissionService->deleteJointPermissionsForEntity($page);
+        $this->searchService->deleteEntityTerms($page);
 
         // Delete Attached Files
         $attachmentService = app(AttachmentService::class);
diff --git a/app/SearchTerm.php b/app/SearchTerm.php
new file mode 100644 (file)
index 0000000..50df340
--- /dev/null
@@ -0,0 +1,18 @@
+<?php namespace BookStack;
+
+class SearchTerm extends Model
+{
+
+    protected $fillable = ['term', 'entity_id', 'entity_type', 'score'];
+    public $timestamps = false;
+
+    /**
+     * Get the entity that this term belongs to
+     * @return \Illuminate\Database\Eloquent\Relations\MorphTo
+     */
+    public function entity()
+    {
+        return $this->morphTo('entity');
+    }
+
+}
index 8b47e1246683c4f30e33976cb4cc84fdfbe58dde..1e75308a07e3734ac6771cf2b78a5c5babe8f3d7 100644 (file)
@@ -479,8 +479,7 @@ class PermissionService
      * @return \Illuminate\Database\Query\Builder
      */
     public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false) {
-        $pageContentSelect = $fetchPageContent ? 'html' : "''";
-        $pageSelect = $this->db->table('pages')->selectRaw("'BookStack\\\\Page' as entity_type, id, slug, name, text, {$pageContentSelect} as description, book_id, priority, chapter_id, draft")->where('book_id', '=', $book_id)->where(function($query) use ($filterDrafts) {
+        $pageSelect = $this->db->table('pages')->selectRaw($this->page->entityRawQuery($fetchPageContent))->where('book_id', '=', $book_id)->where(function($query) use ($filterDrafts) {
             $query->where('draft', '=', 0);
             if (!$filterDrafts) {
                 $query->orWhere(function($query) {
@@ -488,7 +487,7 @@ class PermissionService
                 });
             }
         });
-        $chapterSelect = $this->db->table('chapters')->selectRaw("'BookStack\\\\Chapter' as entity_type, id, slug, name, '' as text, description, book_id, priority, 0 as chapter_id, 0 as draft")->where('book_id', '=', $book_id);
+        $chapterSelect = $this->db->table('chapters')->selectRaw($this->chapter->entityRawQuery())->where('book_id', '=', $book_id);
         $query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U"))
             ->mergeBindings($pageSelect)->mergeBindings($chapterSelect);
 
@@ -514,7 +513,7 @@ class PermissionService
      * @param string $entityType
      * @param Builder|Entity $query
      * @param string $action
-     * @return mixed
+     * @return Builder
      */
     public function enforceEntityRestrictions($entityType, $query, $action = 'view')
     {
@@ -540,7 +539,7 @@ class PermissionService
     }
 
     /**
-     * Filter items that have entities set a a polymorphic relation.
+     * Filter items that have entities set as a polymorphic relation.
      * @param $query
      * @param string $tableName
      * @param string $entityIdColumn
diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php
new file mode 100644 (file)
index 0000000..a3186e8
--- /dev/null
@@ -0,0 +1,472 @@
+<?php namespace BookStack\Services;
+
+use BookStack\Book;
+use BookStack\Chapter;
+use BookStack\Entity;
+use BookStack\Page;
+use BookStack\SearchTerm;
+use Illuminate\Database\Connection;
+use Illuminate\Database\Query\Builder;
+use Illuminate\Database\Query\JoinClause;
+use Illuminate\Support\Collection;
+
+class SearchService
+{
+    protected $searchTerm;
+    protected $book;
+    protected $chapter;
+    protected $page;
+    protected $db;
+    protected $permissionService;
+    protected $entities;
+
+    /**
+     * Acceptable operators to be used in a query
+     * @var array
+     */
+    protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
+
+    /**
+     * SearchService constructor.
+     * @param SearchTerm $searchTerm
+     * @param Book $book
+     * @param Chapter $chapter
+     * @param Page $page
+     * @param Connection $db
+     * @param PermissionService $permissionService
+     */
+    public function __construct(SearchTerm $searchTerm, Book $book, Chapter $chapter, Page $page, Connection $db, PermissionService $permissionService)
+    {
+        $this->searchTerm = $searchTerm;
+        $this->book = $book;
+        $this->chapter = $chapter;
+        $this->page = $page;
+        $this->db = $db;
+        $this->entities = [
+            'page' => $this->page,
+            'chapter' => $this->chapter,
+            'book' => $this->book
+        ];
+        $this->permissionService = $permissionService;
+    }
+
+    /**
+     * Search all entities in the system.
+     * @param string $searchString
+     * @param string $entityType
+     * @param int $page
+     * @param int $count
+     * @return array[int, Collection];
+     */
+    public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20)
+    {
+        $terms = $this->parseSearchString($searchString);
+        $entityTypes = array_keys($this->entities);
+        $entityTypesToSearch = $entityTypes;
+        $results = collect();
+
+        if ($entityType !== 'all') {
+            $entityTypesToSearch = $entityType;
+        } else if (isset($terms['filters']['type'])) {
+            $entityTypesToSearch = explode('|', $terms['filters']['type']);
+        }
+
+        $total = 0;
+
+        foreach ($entityTypesToSearch as $entityType) {
+            if (!in_array($entityType, $entityTypes)) continue;
+            $search = $this->searchEntityTable($terms, $entityType, $page, $count);
+            $total += $this->searchEntityTable($terms, $entityType, $page, $count, true);
+            $results = $results->merge($search);
+        }
+
+        return [
+            'total' => $total,
+            'count' => count($results),
+            'results' => $results->sortByDesc('score')
+        ];
+    }
+
+
+    /**
+     * Search a book for entities
+     * @param integer $bookId
+     * @param string $searchString
+     * @return Collection
+     */
+    public function searchBook($bookId, $searchString)
+    {
+        $terms = $this->parseSearchString($searchString);
+        $entityTypes = ['page', 'chapter'];
+        $entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes;
+
+        $results = collect();
+        foreach ($entityTypesToSearch as $entityType) {
+            if (!in_array($entityType, $entityTypes)) continue;
+            $search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
+            $results = $results->merge($search);
+        }
+        return $results->sortByDesc('score')->take(20);
+    }
+
+    /**
+     * Search a book for entities
+     * @param integer $chapterId
+     * @param string $searchString
+     * @return Collection
+     */
+    public function searchChapter($chapterId, $searchString)
+    {
+        $terms = $this->parseSearchString($searchString);
+        $pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
+        return $pages->sortByDesc('score');
+    }
+
+    /**
+     * Search across a particular entity type.
+     * @param array $terms
+     * @param string $entityType
+     * @param int $page
+     * @param int $count
+     * @param bool $getCount Return the total count of the search
+     * @return \Illuminate\Database\Eloquent\Collection|int|static[]
+     */
+    public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $getCount = false)
+    {
+        $query = $this->buildEntitySearchQuery($terms, $entityType);
+        if ($getCount) return $query->count();
+
+        $query = $query->skip(($page-1) * $count)->take($count);
+        return $query->get();
+    }
+
+    /**
+     * Create a search query for an entity
+     * @param array $terms
+     * @param string $entityType
+     * @return \Illuminate\Database\Eloquent\Builder
+     */
+    protected function buildEntitySearchQuery($terms, $entityType = 'page')
+    {
+        $entity = $this->getEntity($entityType);
+        $entitySelect = $entity->newQuery();
+
+        // Handle normal search terms
+        if (count($terms['search']) > 0) {
+            $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
+            $subQuery->where(function(Builder $query) use ($terms) {
+                foreach ($terms['search'] as $inputTerm) {
+                    $query->orWhere('term', 'like', $inputTerm .'%');
+                }
+            })->groupBy('entity_type', 'entity_id');
+            $entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function(JoinClause $join) {
+                $join->on('id', '=', 'entity_id');
+            })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc');
+            $entitySelect->mergeBindings($subQuery);
+        }
+
+        // Handle exact term matching
+        if (count($terms['exact']) > 0) {
+            $entitySelect->where(function(\Illuminate\Database\Eloquent\Builder $query) use ($terms, $entity) {
+                foreach ($terms['exact'] as $inputTerm) {
+                    $query->where(function (\Illuminate\Database\Eloquent\Builder $query) use ($inputTerm, $entity) {
+                        $query->where('name', 'like', '%'.$inputTerm .'%')
+                            ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
+                    });
+                }
+            });
+        }
+
+        // Handle tag searches
+        foreach ($terms['tags'] as $inputTerm) {
+            $this->applyTagSearch($entitySelect, $inputTerm);
+        }
+
+        // Handle filters
+        foreach ($terms['filters'] as $filterTerm => $filterValue) {
+            $functionName = camel_case('filter_' . $filterTerm);
+            if (method_exists($this, $functionName)) $this->$functionName($entitySelect, $entity, $filterValue);
+        }
+
+        return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, 'view');
+    }
+
+
+    /**
+     * Parse a search string into components.
+     * @param $searchString
+     * @return array
+     */
+    protected function parseSearchString($searchString)
+    {
+        $terms = [
+            'search' => [],
+            'exact' => [],
+            'tags' => [],
+            'filters' => []
+        ];
+
+        $patterns = [
+            'exact' => '/"(.*?)"/',
+            'tags' => '/\[(.*?)\]/',
+            'filters' => '/\{(.*?)\}/'
+        ];
+
+        // Parse special terms
+        foreach ($patterns as $termType => $pattern) {
+            $matches = [];
+            preg_match_all($pattern, $searchString, $matches);
+            if (count($matches) > 0) {
+                $terms[$termType] = $matches[1];
+                $searchString = preg_replace($pattern, '', $searchString);
+            }
+        }
+
+        // Parse standard terms
+        foreach (explode(' ', trim($searchString)) as $searchTerm) {
+            if ($searchTerm !== '') $terms['search'][] = $searchTerm;
+        }
+
+        // Split filter values out
+        $splitFilters = [];
+        foreach ($terms['filters'] as $filter) {
+            $explodedFilter = explode(':', $filter, 2);
+            $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
+        }
+        $terms['filters'] = $splitFilters;
+
+        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
+     * @return Entity
+     */
+    protected function getEntity($type)
+    {
+        return $this->entities[strtolower($type)];
+    }
+
+    /**
+     * Index the given entity.
+     * @param Entity $entity
+     */
+    public function indexEntity(Entity $entity)
+    {
+        $this->deleteEntityTerms($entity);
+        $nameTerms = $this->generateTermArrayFromText($entity->name, 5);
+        $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1);
+        $terms = array_merge($nameTerms, $bodyTerms);
+        foreach ($terms as $index => $term) {
+            $terms[$index]['entity_type'] = $entity->getMorphClass();
+            $terms[$index]['entity_id'] = $entity->id;
+        }
+        $this->searchTerm->newQuery()->insert($terms);
+    }
+
+    /**
+     * Index multiple Entities at once
+     * @param Entity[] $entities
+     */
+    protected function indexEntities($entities) {
+        $terms = [];
+        foreach ($entities as $entity) {
+            $nameTerms = $this->generateTermArrayFromText($entity->name, 5);
+            $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1);
+            foreach (array_merge($nameTerms, $bodyTerms) as $term) {
+                $term['entity_id'] = $entity->id;
+                $term['entity_type'] = $entity->getMorphClass();
+                $terms[] = $term;
+            }
+        }
+
+        $chunkedTerms = array_chunk($terms, 500);
+        foreach ($chunkedTerms as $termChunk) {
+            $this->searchTerm->newQuery()->insert($termChunk);
+        }
+    }
+
+    /**
+     * Delete and re-index the terms for all entities in the system.
+     */
+    public function indexAllEntities()
+    {
+        $this->searchTerm->truncate();
+
+        // Chunk through all books
+        $this->book->chunk(1000, function ($books) {
+            $this->indexEntities($books);
+        });
+
+        // Chunk through all chapters
+        $this->chapter->chunk(1000, function ($chapters) {
+            $this->indexEntities($chapters);
+        });
+
+        // Chunk through all pages
+        $this->page->chunk(1000, function ($pages) {
+            $this->indexEntities($pages);
+        });
+    }
+
+    /**
+     * Delete related Entity search terms.
+     * @param Entity $entity
+     */
+    public function deleteEntityTerms(Entity $entity)
+    {
+        $entity->searchTerms()->delete();
+    }
+
+    /**
+     * Create a scored term array from the given text.
+     * @param $text
+     * @param float|int $scoreAdjustment
+     * @return array
+     */
+    protected function generateTermArrayFromText($text, $scoreAdjustment = 1)
+    {
+        $tokenMap = []; // {TextToken => OccurrenceCount}
+        $splitText = explode(' ', $text);
+        foreach ($splitText as $token) {
+            if ($token === '') continue;
+            if (!isset($tokenMap[$token])) $tokenMap[$token] = 0;
+            $tokenMap[$token]++;
+        }
+
+        $terms = [];
+        foreach ($tokenMap as $token => $count) {
+            $terms[] = [
+                'term' => $token,
+                'score' => $count * $scoreAdjustment
+            ];
+        }
+        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) && $input !== 'me') return;
+        if ($input === 'me') $input = user()->id;
+        $query->where('created_by', '=', $input);
+    }
+
+    protected function filterUpdatedBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
+    {
+        if (!is_numeric($input) && $input !== 'me') return;
+        if ($input === 'me') $input = user()->id;
+        $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
index 99e5a28f08d797ac294b485f1b0cc13e1dbeb897..127f69d281415fccc24d6cd10e3d8b452a21b495 100644 (file)
@@ -12,9 +12,10 @@ class AddSearchIndexes extends Migration
      */
     public function up()
     {
-        DB::statement('ALTER TABLE pages ADD FULLTEXT search(name, text)');
-        DB::statement('ALTER TABLE books ADD FULLTEXT search(name, description)');
-        DB::statement('ALTER TABLE chapters ADD FULLTEXT search(name, description)');
+        $prefix = DB::getTablePrefix();
+        DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT search(name, text)");
+        DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT search(name, description)");
+        DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT search(name, description)");
     }
 
     /**
index cef43f604419673f35bf3d71dc45a2b5817987e8..99813138764893f34571512225d07bf5d2fa9d13 100644 (file)
@@ -12,9 +12,10 @@ class FulltextWeighting extends Migration
      */
     public function up()
     {
-        DB::statement('ALTER TABLE pages ADD FULLTEXT name_search(name)');
-        DB::statement('ALTER TABLE books ADD FULLTEXT name_search(name)');
-        DB::statement('ALTER TABLE chapters ADD FULLTEXT name_search(name)');
+        $prefix = DB::getTablePrefix();
+        DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT name_search(name)");
+        DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT name_search(name)");
+        DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT name_search(name)");
     }
 
     /**
diff --git a/database/migrations/2017_03_19_091553_create_search_index_table.php b/database/migrations/2017_03_19_091553_create_search_index_table.php
new file mode 100644 (file)
index 0000000..32c6a09
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateSearchIndexTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('search_terms', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('term', 200);
+            $table->string('entity_type', 100);
+            $table->integer('entity_id');
+            $table->integer('score');
+
+            $table->index('term');
+            $table->index('entity_type');
+            $table->index(['entity_type', 'entity_id']);
+            $table->index('score');
+        });
+
+        // Drop search indexes
+        Schema::table('pages', function(Blueprint $table) {
+            $table->dropIndex('search');
+            $table->dropIndex('name_search');
+        });
+        Schema::table('books', function(Blueprint $table) {
+            $table->dropIndex('search');
+            $table->dropIndex('name_search');
+        });
+        Schema::table('chapters', function(Blueprint $table) {
+            $table->dropIndex('search');
+            $table->dropIndex('name_search');
+        });
+
+        app(\BookStack\Services\SearchService::class)->indexAllEntities();
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        $prefix = DB::getTablePrefix();
+        DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT search(name, text)");
+        DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT search(name, description)");
+        DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT search(name, description)");
+        DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT name_search(name)");
+        DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT name_search(name)");
+        DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT name_search(name)");
+
+        Schema::dropIfExists('search_terms');
+    }
+}
index efcda422018cd29fb146829ed5a8d5843dce1490..6f6b3ddc57e7b826a1417af290c84274df895ec5 100644 (file)
@@ -16,7 +16,7 @@ class DummyContentSeeder extends Seeder
         $user->attachRole($role);
 
 
-        $books = factory(\BookStack\Book::class, 20)->create(['created_by' => $user->id, 'updated_by' => $user->id])
+        factory(\BookStack\Book::class, 20)->create(['created_by' => $user->id, 'updated_by' => $user->id])
             ->each(function($book) use ($user) {
                 $chapters = factory(\BookStack\Chapter::class, 5)->create(['created_by' => $user->id, 'updated_by' => $user->id])
                     ->each(function($chapter) use ($user, $book){
@@ -28,7 +28,7 @@ class DummyContentSeeder extends Seeder
                 $book->pages()->saveMany($pages);
             });
 
-        $restrictionService = app(\BookStack\Services\PermissionService::class);
-        $restrictionService->buildJointPermissions();
+        app(\BookStack\Services\PermissionService::class)->buildJointPermissions();
+        app(\BookStack\Services\SearchService::class)->indexAllEntities();
     }
 }
index 9d789d9b4c40c425cd0e198b504e0aa616abc55a..b72bb366d6700ef1cfc6a63f56604cefff987863 100644 (file)
@@ -1,8 +1,63 @@
-var elixir = require('laravel-elixir');
+const argv = require('yargs').argv;
+const gulp = require('gulp'),
+    plumber = require('gulp-plumber');
+const autoprefixer = require('gulp-autoprefixer');
+const uglify = require('gulp-uglify');
+const minifycss = require('gulp-clean-css');
+const sass = require('gulp-sass');
+const browserify = require("browserify");
+const source = require('vinyl-source-stream');
+const buffer = require('vinyl-buffer');
+const babelify = require("babelify");
+const watchify = require("watchify");
+const envify = require("envify");
+const gutil = require("gulp-util");
 
-elixir(mix => {
-    mix.sass('styles.scss');
-    mix.sass('print-styles.scss');
-    mix.sass('export-styles.scss');
-    mix.browserify('global.js', './public/js/common.js');
+if (argv.production) process.env.NODE_ENV = 'production';
+
+gulp.task('styles', () => {
+    let chain = gulp.src(['resources/assets/sass/**/*.scss'])
+        .pipe(plumber({
+            errorHandler: function (error) {
+                console.log(error.message);
+                this.emit('end');
+            }}))
+        .pipe(sass())
+        .pipe(autoprefixer('last 2 versions'));
+    if (argv.production) chain = chain.pipe(minifycss());
+    return chain.pipe(gulp.dest('public/css/'));
 });
+
+
+function scriptTask(watch=false) {
+
+    let props = {
+        basedir: 'resources/assets/js',
+        debug: true,
+        entries: ['global.js']
+    };
+
+    let bundler = watch ? watchify(browserify(props), { poll: true }) : browserify(props);
+    bundler.transform(envify, {global: true}).transform(babelify, {presets: ['es2015']});
+    function rebundle() {
+        let stream = bundler.bundle();
+        stream = stream.pipe(source('common.js'));
+        if (argv.production) stream = stream.pipe(buffer()).pipe(uglify());
+        return stream.pipe(gulp.dest('public/js/'));
+    }
+    bundler.on('update', function() {
+        rebundle();
+        gutil.log('Rebundle...');
+    });
+    bundler.on('log', gutil.log);
+    return rebundle();
+}
+
+gulp.task('scripts', () => {scriptTask(false)});
+gulp.task('scripts-watch', () => {scriptTask(true)});
+
+gulp.task('default', ['styles', 'scripts-watch'], () => {
+    gulp.watch("resources/assets/sass/**/*.scss", ['styles']);
+});
+
+gulp.task('build', ['styles', 'scripts']);
\ No newline at end of file
index b0805c91836ff5ac9e12a1e9d1f6790fbc5c707b..9f2ce4c1ae6d96c8df3a599ae1c420b0e1a56db7 100644 (file)
@@ -1,24 +1,43 @@
 {
   "private": true,
   "scripts": {
-    "build": "gulp --production",
-    "dev": "gulp watch",
-    "watch": "gulp watch"
+    "build": "gulp build",
+    "production": "gulp build --production",
+    "dev": "gulp",
+    "watch": "gulp"
   },
   "devDependencies": {
+    "babelify": "^7.3.0",
+    "browserify": "^14.3.0",
+    "envify": "^4.0.0",
+    "gulp": "3.9.1",
+    "gulp-autoprefixer": "3.1.1",
+    "gulp-clean-css": "^3.0.4",
+    "gulp-minify-css": "1.2.4",
+    "gulp-plumber": "1.1.0",
+    "gulp-sass": "3.1.0",
+    "gulp-uglify": "2.1.2",
+    "vinyl-buffer": "^1.0.0",
+    "vinyl-source-stream": "^1.1.0",
+    "watchify": "^3.9.0",
+    "yargs": "^7.1.0"
+  },
+  "dependencies": {
     "angular": "^1.5.5",
     "angular-animate": "^1.5.5",
     "angular-resource": "^1.5.5",
     "angular-sanitize": "^1.5.5",
-    "angular-ui-sortable": "^0.15.0",
+    "angular-ui-sortable": "^0.17.0",
+    "axios": "^0.16.1",
+    "babel-preset-es2015": "^6.24.1",
+    "clipboard": "^1.5.16",
     "dropzone": "^4.0.1",
-    "gulp": "^3.9.0",
-    "laravel-elixir": "^6.0.0-11",
-    "laravel-elixir-browserify-official": "^0.1.3",
+    "gulp-util": "^3.0.8",
     "marked": "^0.3.5",
-    "moment": "^2.12.0"
+    "moment": "^2.12.0",
+    "vue": "^2.2.6"
   },
-  "dependencies": {
-    "clipboard": "^1.5.16"
+  "browser": {
+    "vue": "vue/dist/vue.common.js"
   }
 }
index 0d57b09add8307f7284ceb9c417f7bd768751a1f..6a88aa81152a6822b81071a77c9f9aa2ccda5870 100644 (file)
@@ -1,12 +1,12 @@
 "use strict";
 
-import moment from 'moment';
-import 'moment/locale/en-gb';
-import editorOptions from "./pages/page-form";
+const moment = require('moment');
+require('moment/locale/en-gb');
+const editorOptions = require("./pages/page-form");
 
 moment.locale('en-gb');
 
-export default function (ngApp, events) {
+module.exports = function (ngApp, events) {
 
     ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService',
         function ($scope, $attrs, $http, $timeout, imageManagerService) {
@@ -259,39 +259,6 @@ export default function (ngApp, events) {
 
         }]);
 
-
-    ngApp.controller('BookShowController', ['$scope', '$http', '$attrs', '$sce', function ($scope, $http, $attrs, $sce) {
-        $scope.searching = false;
-        $scope.searchTerm = '';
-        $scope.searchResults = '';
-
-        $scope.searchBook = function (e) {
-            e.preventDefault();
-            let term = $scope.searchTerm;
-            if (term.length == 0) return;
-            $scope.searching = true;
-            $scope.searchResults = '';
-            let searchUrl = window.baseUrl('/search/book/' + $attrs.bookId);
-            searchUrl += '?term=' + encodeURIComponent(term);
-            $http.get(searchUrl).then((response) => {
-                $scope.searchResults = $sce.trustAsHtml(response.data);
-            });
-        };
-
-        $scope.checkSearchForm = function () {
-            if ($scope.searchTerm.length < 1) {
-                $scope.searching = false;
-            }
-        };
-
-        $scope.clearSearch = function () {
-            $scope.searching = false;
-            $scope.searchTerm = '';
-        };
-
-    }]);
-
-
     ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', '$sce',
         function ($scope, $http, $attrs, $interval, $timeout, $sce) {
 
index 10458e753fac0809a8fe9678affa5f4ade0bbac9..19badcac83e5f570101305b250b3f099a8cef559 100644 (file)
@@ -1,8 +1,8 @@
 "use strict";
-import DropZone from "dropzone";
-import markdown from "marked";
+const DropZone = require("dropzone");
+const markdown = require("marked");
 
-export default function (ngApp, events) {
+module.exports = function (ngApp, events) {
 
     /**
      * Common tab controls using simple jQuery functions.
index 650919f85e7c243837c02cafce0827bece3fa0ea..dc6802e12ba6646be4099f759773f2368e2c5ffe 100644 (file)
@@ -1,12 +1,5 @@
 "use strict";
 
-// AngularJS - Create application and load components
-import angular from "angular";
-import "angular-resource";
-import "angular-animate";
-import "angular-sanitize";
-import "angular-ui-sortable";
-
 // Url retrieval function
 window.baseUrl = function(path) {
     let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content');
@@ -15,11 +8,33 @@ window.baseUrl = function(path) {
     return basePath + '/' + path;
 };
 
+const Vue = require("vue");
+const axios = require("axios");
+
+let axiosInstance = axios.create({
+    headers: {
+        'X-CSRF-TOKEN': document.querySelector('meta[name=token]').getAttribute('content'),
+        'baseURL': window.baseUrl('')
+    }
+});
+
+Vue.prototype.$http = axiosInstance;
+
+require("./vues/vues");
+
+
+// AngularJS - Create application and load components
+const angular = require("angular");
+require("angular-resource");
+require("angular-animate");
+require("angular-sanitize");
+require("angular-ui-sortable");
+
 let ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']);
 
 // Translation setup
 // Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
-import Translations from "./translations"
+const Translations = require("./translations");
 let translator = new Translations(window.translations);
 window.trans = translator.get.bind(translator);
 
@@ -47,11 +62,12 @@ class EventManager {
 }
 
 window.Events = new EventManager();
+Vue.prototype.$events = window.Events;
 
 // Load in angular specific items
-import Services from './services';
-import Directives from './directives';
-import Controllers from './controllers';
+const Services = require('./services');
+const Directives = require('./directives');
+const Controllers = require('./controllers');
 Services(ngApp, window.Events);
 Directives(ngApp, window.Events);
 Controllers(ngApp, window.Events);
@@ -154,4 +170,4 @@ if(navigator.userAgent.indexOf('MSIE')!==-1
 }
 
 // Page specific items
-import "./pages/page-show";
+require("./pages/page-show");
index 2ad934cd9099be33a633c4f6d336dc99c8ee7a7c..f8bdf2e2b98804e8aec97c5460feab04cfbfa8cd 100644 (file)
@@ -60,7 +60,7 @@ function registerEditorShortcuts(editor) {
     editor.addShortcut('meta+shift+E', '', ['FormatBlock', false, 'code']);
 }
 
-export default function() {
+module.exports = function() {
     let settings = {
         selector: '#html-editor',
         content_css: [
@@ -214,4 +214,4 @@ export default function() {
         }
     };
     return settings;
-}
\ No newline at end of file
+};
\ No newline at end of file
index 0f45e1987cf434959169128a0632692cc1937aa9..cc6296434ee41c78db8494cd9e6d86338c30e67a 100644 (file)
@@ -1,8 +1,8 @@
 "use strict";
 // Configure ZeroClipboard
-import Clipboard from "clipboard";
+const Clipboard = require("clipboard");
 
-export default window.setupPageShow = function (pageId) {
+let setupPageShow = window.setupPageShow = function (pageId) {
 
     // Set up pointer
     let $pointer = $('#pointer').detach();
@@ -151,3 +151,5 @@ export default window.setupPageShow = function (pageId) {
     });
 
 };
+
+module.exports = setupPageShow;
\ No newline at end of file
index 306c696b6efbf92a48f516167bc665206cc5fd6f..ca6a7bd29a8f7224a31c8dae62f2c4f8a58f5266 100644 (file)
@@ -44,4 +44,4 @@ class Translator {
 
 }
 
-export default Translator
+module.exports = Translator;
diff --git a/resources/assets/js/vues/entity-search.js b/resources/assets/js/vues/entity-search.js
new file mode 100644 (file)
index 0000000..7266bf3
--- /dev/null
@@ -0,0 +1,44 @@
+let data = {
+    id: null,
+    type: '',
+    searching: false,
+    searchTerm: '',
+    searchResults: '',
+};
+
+let computed = {
+
+};
+
+let methods = {
+
+    searchBook() {
+        if (this.searchTerm.trim().length === 0) return;
+        this.searching = true;
+        this.searchResults = '';
+        let url = window.baseUrl(`/search/${this.type}/${this.id}`);
+        url += `?term=${encodeURIComponent(this.searchTerm)}`;
+        this.$http.get(url).then(resp => {
+            this.searchResults = resp.data;
+        });
+    },
+
+    checkSearchForm() {
+        this.searching = this.searchTerm > 0;
+    },
+
+    clearSearch() {
+        this.searching = false;
+        this.searchTerm = '';
+    }
+
+};
+
+function mounted() {
+    this.id = Number(this.$el.getAttribute('entity-id'));
+    this.type = this.$el.getAttribute('entity-type');
+}
+
+module.exports = {
+    data, computed, methods, mounted
+};
\ No newline at end of file
diff --git a/resources/assets/js/vues/search.js b/resources/assets/js/vues/search.js
new file mode 100644 (file)
index 0000000..515ca3b
--- /dev/null
@@ -0,0 +1,195 @@
+const moment = require('moment');
+
+let data = {
+    terms: '',
+    termString : '',
+    search: {
+        type: {
+            page: true,
+            chapter: true,
+            book: true
+        },
+        exactTerms: [],
+        tagTerms: [],
+        option: {},
+        dates: {
+            updated_after: false,
+            updated_before: false,
+            created_after: false,
+            created_before: false,
+        }
+    }
+};
+
+let computed = {
+
+};
+
+let methods = {
+
+    appendTerm(term) {
+        this.termString += ' ' + term;
+        this.termString = this.termString.replace(/\s{2,}/g, ' ');
+        this.termString = this.termString.replace(/^\s+/, '');
+        this.termString = this.termString.replace(/\s+$/, '');
+    },
+
+    exactParse(searchString) {
+        this.search.exactTerms = [];
+        let exactFilter = /"(.+?)"/g;
+        let matches;
+        while ((matches = exactFilter.exec(searchString)) !== null) {
+            this.search.exactTerms.push(matches[1]);
+        }
+    },
+
+    exactChange() {
+        let exactFilter = /"(.+?)"/g;
+        this.termString = this.termString.replace(exactFilter, '');
+        let matchesTerm = this.search.exactTerms.filter(term => {
+            return term.trim() !== '';
+        }).map(term => {
+            return `"${term}"`
+        }).join(' ');
+        this.appendTerm(matchesTerm);
+    },
+
+    addExact() {
+        this.search.exactTerms.push('');
+        setTimeout(() => {
+            let exactInputs = document.querySelectorAll('.exact-input');
+            exactInputs[exactInputs.length - 1].focus();
+        }, 100);
+    },
+
+    removeExact(index) {
+        this.search.exactTerms.splice(index, 1);
+        this.exactChange();
+    },
+
+    tagParse(searchString) {
+        this.search.tagTerms = [];
+        let tagFilter = /\[(.+?)\]/g;
+        let matches;
+        while ((matches = tagFilter.exec(searchString)) !== null) {
+            this.search.tagTerms.push(matches[1]);
+        }
+    },
+
+    tagChange() {
+        let tagFilter = /\[(.+?)\]/g;
+        this.termString = this.termString.replace(tagFilter, '');
+        let matchesTerm = this.search.tagTerms.filter(term => {
+            return term.trim() !== '';
+        }).map(term => {
+            return `[${term}]`
+        }).join(' ');
+        this.appendTerm(matchesTerm);
+    },
+
+    addTag() {
+        this.search.tagTerms.push('');
+        setTimeout(() => {
+            let tagInputs = document.querySelectorAll('.tag-input');
+            tagInputs[tagInputs.length - 1].focus();
+        }, 100);
+    },
+
+    removeTag(index) {
+        this.search.tagTerms.splice(index, 1);
+        this.tagChange();
+    },
+
+    typeParse(searchString) {
+        let typeFilter = /{\s?type:\s?(.*?)\s?}/;
+        let match = searchString.match(typeFilter);
+        let type = this.search.type;
+        if (!match) {
+            type.page = type.book = type.chapter = true;
+            return;
+        }
+        let splitTypes = match[1].replace(/ /g, '').split('|');
+        type.page = (splitTypes.indexOf('page') !== -1);
+        type.chapter = (splitTypes.indexOf('chapter') !== -1);
+        type.book = (splitTypes.indexOf('book') !== -1);
+    },
+
+    typeChange() {
+        let typeFilter = /{\s?type:\s?(.*?)\s?}/;
+        let type = this.search.type;
+        if (type.page === type.chapter && type.page === type.book) {
+            this.termString = this.termString.replace(typeFilter, '');
+            return;
+        }
+        let selectedTypes = Object.keys(type).filter(type => {return this.search.type[type];}).join('|');
+        let typeTerm = '{type:'+selectedTypes+'}';
+        if (this.termString.match(typeFilter)) {
+            this.termString = this.termString.replace(typeFilter, typeTerm);
+            return;
+        }
+        this.appendTerm(typeTerm);
+    },
+
+    optionParse(searchString) {
+        let optionFilter = /{([a-z_\-:]+?)}/gi;
+        let matches;
+        while ((matches = optionFilter.exec(searchString)) !== null) {
+            this.search.option[matches[1].toLowerCase()] = true;
+        }
+    },
+
+    optionChange(optionName) {
+        let isChecked = this.search.option[optionName];
+        if (isChecked) {
+            this.appendTerm(`{${optionName}}`);
+        } else {
+            this.termString = this.termString.replace(`{${optionName}}`, '');
+        }
+    },
+
+    updateSearch(e) {
+        e.preventDefault();
+        window.location = '/search?term=' + encodeURIComponent(this.termString);
+    },
+
+    enableDate(optionName) {
+        this.search.dates[optionName.toLowerCase()] = moment().format('YYYY-MM-DD');
+        this.dateChange(optionName);
+    },
+
+    dateParse(searchString) {
+        let dateFilter = /{([a-z_\-]+?):([a-z_\-0-9]+?)}/gi;
+        let dateTags = Object.keys(this.search.dates);
+        let matches;
+        while ((matches = dateFilter.exec(searchString)) !== null) {
+            if (dateTags.indexOf(matches[1]) === -1) continue;
+            this.search.dates[matches[1].toLowerCase()] = matches[2];
+        }
+    },
+
+    dateChange(optionName) {
+        let dateFilter = new RegExp('{\\s?'+optionName+'\\s?:([a-z_\\-0-9]+?)}', 'gi');
+        this.termString = this.termString.replace(dateFilter, '');
+        if (!this.search.dates[optionName]) return;
+        this.appendTerm(`{${optionName}:${this.search.dates[optionName]}}`);
+    },
+
+    dateRemove(optionName) {
+        this.search.dates[optionName] = false;
+        this.dateChange(optionName);
+    }
+
+};
+
+function created() {
+    this.termString = document.querySelector('[name=searchTerm]').value;
+    this.typeParse(this.termString);
+    this.exactParse(this.termString);
+    this.tagParse(this.termString);
+    this.optionParse(this.termString);
+    this.dateParse(this.termString);
+}
+
+module.exports = {
+    data, computed, methods, created
+};
\ No newline at end of file
diff --git a/resources/assets/js/vues/vues.js b/resources/assets/js/vues/vues.js
new file mode 100644 (file)
index 0000000..8cc1dd6
--- /dev/null
@@ -0,0 +1,18 @@
+const Vue = require("vue");
+
+function exists(id) {
+    return document.getElementById(id) !== null;
+}
+
+let vueMapping = {
+    'search-system': require('./search'),
+    'entity-dashboard': require('./entity-search'),
+};
+
+Object.keys(vueMapping).forEach(id => {
+    if (exists(id)) {
+        let config = vueMapping[id];
+        config.el = '#' + id;
+        new Vue(config);
+    }
+});
\ No newline at end of file
index 582d718c86ff741327e58761e357115ab58ea21c..afcf01cffb2a030591f583e4dcb13774c06b61ee 100644 (file)
@@ -2,7 +2,7 @@
 .anim.fadeIn {
   opacity: 0;
   animation-name: fadeIn;
-  animation-duration: 160ms;
+  animation-duration: 180ms;
   animation-timing-function: ease-in-out;
   animation-fill-mode: forwards;
 }
index 7e6b800d2dbe1575d83d9119475fd778b661e439..1fc8128966aa985f360003332b8a500628d5c89a 100644 (file)
@@ -98,19 +98,36 @@ label {
 
 label.radio, label.checkbox {
   font-weight: 400;
+  user-select: none;
   input[type="radio"], input[type="checkbox"] {
     margin-right: $-xs;
   }
 }
 
+label.inline.checkbox {
+  margin-right: $-m;
+}
+
 label + p.small {
   margin-bottom: 0.8em;
 }
 
-input[type="text"], input[type="number"], input[type="email"], input[type="search"], input[type="url"], input[type="password"], select, textarea {
+table.form-table {
+  max-width: 100%;
+  td {
+    overflow: hidden;
+    padding: $-xxs/2 0;
+  }
+}
+
+input[type="text"], input[type="number"], input[type="email"], input[type="date"], input[type="search"], input[type="url"], input[type="password"], select, textarea {
   @extend .input-base;
 }
 
+input[type=date] {
+  width: 190px;
+}
+
 .toggle-switch {
   display: inline-block;
   background-color: #BBB;
index 6acc4746881a6d669d5f867e7cec1b2da8bbf7da..05126892618449e35bf3f47adea7ea080c0cbde5 100644 (file)
   transition-property: right, border;
   border-left: 0px solid #FFF;
   background-color: #FFF;
+  max-width: 320px;
   &.fixed {
     background-color: #FFF;
     z-index: 5;
index 967aba76b755796c44c798f15ab94331c3c2fdd9..50c3a50b2b8cce231cfc6fe46cf1d8052ec7f5ee 100644 (file)
@@ -7,8 +7,8 @@
 @import "grid";
 @import "blocks";
 @import "buttons";
-@import "forms";
 @import "tables";
+@import "forms";
 @import "animations";
 @import "tinymce";
 @import "highlightjs";
 @import "lists";
 @import "pages";
 
-[v-cloak], [v-show] {display: none;}
+[v-cloak], [v-show] {
+  display: none; opacity: 0;
+  animation-name: none !important;
+}
+
 
 [ng\:cloak], [ng-cloak], .ng-cloak {
   display: none !important;
@@ -272,8 +276,3 @@ $btt-size: 40px;
 
 
 
-
-
-
-
-
index 2859e4ec5d21a1cd6c903e0bea9bdea5e34baeb8..c9feb8497920b9e7c3ef656745c0335f5ef49073 100644 (file)
@@ -43,18 +43,9 @@ return [
      * Search
      */
     'search_results' => 'Suchergebnisse',
-    'search_results_page' => 'Seiten-Suchergebnisse',
-    'search_results_chapter' => 'Kapitel-Suchergebnisse',
-    'search_results_book' => 'Buch-Suchergebnisse',
     'search_clear' => 'Suche zur&uuml;cksetzen',
-    'search_view_pages' => 'Zeige alle passenden Seiten',
-    'search_view_chapters' => 'Zeige alle passenden Kapitel',
-    'search_view_books' => 'Zeige alle passenden B&uuml;cher',
     'search_no_pages' => 'Es wurden keine passenden Suchergebnisse gefunden',
     'search_for_term' => 'Suche nach :term',
-    'search_page_for_term' => 'Suche nach :term in Seiten',
-    'search_chapter_for_term' => 'Suche nach :term in Kapiteln',
-    'search_book_for_term' => 'Suche nach :term in B&uuml;chern',
 
     /**
      * Books
index 31ef42e97e0045e57ff5de563b71d40c40b35869..e1d74c95e0e0718f0be435c5f40fdbb3da9f8e40 100644 (file)
@@ -33,6 +33,7 @@ return [
     'search_clear' => 'Clear Search',
     'reset' => 'Reset',
     'remove' => 'Remove',
+    'add' => 'Add',
 
 
     /**
index f54134718707193d33ff50593d5fdd1c02c0c0d6..8644f7a4a839bfa8e7a2b4952a8d6d6e5c056162 100644 (file)
@@ -43,18 +43,26 @@ return [
      * Search
      */
     'search_results' => 'Search Results',
-    'search_results_page' => 'Page Search Results',
-    'search_results_chapter' => 'Chapter Search Results',
-    'search_results_book' => 'Book Search Results',
+    'search_total_results_found' => ':count result found|:count total results found',
     'search_clear' => 'Clear Search',
-    'search_view_pages' => 'View all matches pages',
-    'search_view_chapters' => 'View all matches chapters',
-    'search_view_books' => 'View all matches books',
     'search_no_pages' => 'No pages matched this search',
     'search_for_term' => 'Search for :term',
-    'search_page_for_term' => 'Page search for :term',
-    'search_chapter_for_term' => 'Chapter search for :term',
-    'search_book_for_term' => 'Books search for :term',
+    'search_more' => 'More Results',
+    'search_filters' => 'Search Filters',
+    'search_content_type' => 'Content Type',
+    'search_exact_matches' => 'Exact Matches',
+    'search_tags' => 'Tag Searches',
+    'search_viewed_by_me' => 'Viewed by me',
+    'search_not_viewed_by_me' => 'Not viewed by me',
+    'search_permissions_set' => 'Permissions set',
+    'search_created_by_me' => 'Created by me',
+    'search_updated_by_me' => 'Updated by me',
+    'search_updated_before' => 'Updated before',
+    'search_updated_after' => 'Updated after',
+    'search_created_before' => 'Created before',
+    'search_created_after' => 'Created after',
+    'search_set_date' => 'Set Date',
+    'search_update' => 'Update Search',
 
     /**
      * Books
@@ -112,6 +120,7 @@ return [
     'chapters_empty' => 'No pages are currently in this chapter.',
     'chapters_permissions_active' => 'Chapter Permissions Active',
     'chapters_permissions_success' => 'Chapter Permissions Updated',
+    'chapters_search_this' => 'Search this chapter',
 
     /**
      * Pages
index 14e952f1ac646c69278a80707cab015236909522..b03366da6abd33b10f03fef49e3d39028650e44d 100644 (file)
@@ -43,18 +43,9 @@ return [
      * Search
      */
     'search_results' => 'Buscar resultados',
-    'search_results_page' => 'resultados de búsqueda en página',
-    'search_results_chapter' => 'Resultados de búsqueda en capítulo ',
-    'search_results_book' => 'Resultados de búsqueda en libro',
     'search_clear' => 'Limpiar resultados',
-    'search_view_pages' => 'Ver todas las páginas que concuerdan',
-    'search_view_chapters' => 'Ver todos los capítulos que concuerdan',
-    'search_view_books' => 'Ver todos los libros que concuerdan',
     'search_no_pages' => 'Ninguna página encontrada para la búsqueda',
     'search_for_term' => 'Busqueda por :term',
-    'search_page_for_term' => 'Búsqueda de página por :term',
-    'search_chapter_for_term' => 'Búsqueda por capítulo de :term',
-    'search_book_for_term' => 'Búsqueda en libro de :term',
 
     /**
      * Books
index cfd206b919ad8d8c75c0572783a55743fd7ccb39..5562fb0fd47013119d97b45f0b4050f9c8408bdd 100644 (file)
@@ -43,18 +43,9 @@ return [
      * Search
      */
     'search_results' => 'Résultats de recherche',
-    'search_results_page' => 'Résultats de recherche des pages',
-    'search_results_chapter' => 'Résultats de recherche des chapitres',
-    'search_results_book' => 'Résultats de recherche des livres',
     'search_clear' => 'Réinitialiser la recherche',
-    'search_view_pages' => 'Voir toutes les pages correspondantes',
-    'search_view_chapters' => 'Voir tous les chapitres correspondants',
-    'search_view_books' => 'Voir tous les livres correspondants',
     'search_no_pages' => 'Aucune page correspondant à cette recherche',
     'search_for_term' => 'recherche pour :term',
-    'search_page_for_term' => 'Recherche de page pour :term',
-    'search_chapter_for_term' => 'Recherche de chapitre pour :term',
-    'search_book_for_term' => 'Recherche de livres pour :term',
 
     /**
      * Books
index 610116c8bd906b8c11b98f45ca28fe79c8e2c53d..d6975e130a6ed4b70f695b362ba99b6db769121d 100644 (file)
@@ -43,18 +43,9 @@ return [
      * Search
      */
     'search_results' => 'Zoekresultaten',
-    'search_results_page' => 'Pagina Zoekresultaten',
-    'search_results_chapter' => 'Hoofdstuk Zoekresultaten',
-    'search_results_book' => 'Boek Zoekresultaten',
     'search_clear' => 'Zoekopdracht wissen',
-    'search_view_pages' => 'Bekijk alle gevonden pagina\'s',
-    'search_view_chapters' => 'Bekijk alle gevonden hoofdstukken',
-    'search_view_books' => 'Bekijk alle gevonden boeken',
     'search_no_pages' => 'Er zijn geen pagina\'s gevonden',
     'search_for_term' => 'Zoeken op :term',
-    'search_page_for_term' => 'Pagina doorzoeken op :term',
-    'search_chapter_for_term' => 'Hoofdstuk doorzoeken op :term',
-    'search_book_for_term' => 'Boeken doorzoeken op :term',
 
     /**
      * Books
index 922342424b49f4c99a3335fb2aee45de158360f7..5a965fe624fe0926346c27e3cb2dec9dbbb4180a 100644 (file)
@@ -43,18 +43,9 @@ return [
      * Search
      */
     'search_results' => 'Resultado(s) da Pesquisa',
-    'search_results_page' => 'Resultado(s) de Pesquisa de Página',
-    'search_results_chapter' => 'Resultado(s) de Pesquisa de Capítulo',
-    'search_results_book' => 'Resultado(s) de Pesquisa de Livro',
     'search_clear' => 'Limpar Pesquisa',
-    'search_view_pages' => 'Visualizar todas as páginas correspondentes',
-    'search_view_chapters' => 'Visualizar todos os capítulos correspondentes',
-    'search_view_books' => 'Visualizar todos os livros correspondentes',
     'search_no_pages' => 'Nenhuma página corresponde à pesquisa',
     'search_for_term' => 'Pesquisar por :term',
-    'search_page_for_term' => 'Pesquisar Página por :term',
-    'search_chapter_for_term' => 'Pesquisar Capítulo por :term',
-    'search_book_for_term' => 'Pesquisar Livros por :term',
 
     /**
      * Books
index 4287014c20a8afd78d80345b57ed6ab89e262ac7..95a9d72b0dcff779bfea147298a97ca208a37129 100644 (file)
@@ -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>
index f5e08b2f6e386808059ac04025a07df603a96fa3..adfec45256db85b7d915c199a31cc34c9e442f33 100644 (file)
     </div>
 
 
-    <div class="container" id="book-dashboard" ng-controller="BookShowController" book-id="{{ $book->id }}">
+    <div class="container" id="entity-dashboard" entity-id="{{ $book->id }}" entity-type="book">
         <div class="row">
             <div class="col-md-7">
 
                 <h1>{{$book->name}}</h1>
-                <div class="book-content" ng-show="!searching">
-                    <p class="text-muted" ng-non-bindable>{{$book->description}}</p>
+                <div class="book-content" v-if="!searching">
+                    <p class="text-muted" v-pre>{{$book->description}}</p>
 
-                    <div class="page-list" ng-non-bindable>
+                    <div class="page-list" v-pre>
                         <hr>
                         @if(count($bookChildren) > 0)
                             @foreach($bookChildren as $childElement)
                         @include('partials.entity-meta', ['entity' => $book])
                     </div>
                 </div>
-                <div class="search-results" ng-cloak ng-show="searching">
-                    <h3 class="text-muted">{{ trans('entities.search_results') }} <a ng-if="searching" ng-click="clearSearch()" class="text-small"><i class="zmdi zmdi-close"></i>{{ trans('entities.search_clear') }}</a></h3>
-                    <div ng-if="!searchResults">
+                <div class="search-results" v-cloak v-if="searching">
+                    <h3 class="text-muted">{{ trans('entities.search_results') }} <a v-if="searching" v-on:click="clearSearch()" class="text-small"><i class="zmdi zmdi-close"></i>{{ trans('entities.search_clear') }}</a></h3>
+                    <div v-if="!searchResults">
                         @include('partials/loading-icon')
                     </div>
-                    <div ng-bind-html="searchResults"></div>
+                    <div v-html="searchResults"></div>
                 </div>
 
 
@@ -94,6 +94,7 @@
 
             <div class="col-md-4 col-md-offset-1">
                 <div class="margin-top large"></div>
+
                 @if($book->restricted)
                     <p class="text-muted">
                         @if(userCan('restrictions-manage', $book))
                         @endif
                     </p>
                 @endif
+
                 <div class="search-box">
-                    <form ng-submit="searchBook($event)">
-                        <input ng-model="searchTerm" ng-change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.books_search_this') }}">
+                    <form v-on:submit="searchBook">
+                        <input v-model="searchTerm" v-on:change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.books_search_this') }}">
                         <button type="submit"><i class="zmdi zmdi-search"></i></button>
-                        <button ng-if="searching" ng-click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button>
+                        <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button>
                     </form>
                 </div>
-                <div class="activity anim fadeIn">
+                
+                <div class="activity">
                     <h3>{{ trans('entities.recent_activity') }}</h3>
                     @include('partials/activity-list', ['activity' => Activity::entityActivity($book, 20, 0)])
                 </div>
index 28c34eef224032f37b492b510b56037a4dc9af13..d4126cbccee764fb95fd0d27dcb84d93a2df79c0 100644 (file)
     </div>
 
 
-    <div class="container" ng-non-bindable>
+    <div class="container" id="entity-dashboard" entity-id="{{ $chapter->id }}" entity-type="chapter">
         <div class="row">
-            <div class="col-md-8">
+            <div class="col-md-7">
                 <h1>{{ $chapter->name }}</h1>
-                <p class="text-muted">{{ $chapter->description }}</p>
+                <div class="chapter-content" v-if="!searching">
+                    <p class="text-muted">{{ $chapter->description }}</p>
 
-                @if(count($pages) > 0)
-                    <div class="page-list">
-                        <hr>
-                        @foreach($pages as $page)
-                            @include('pages/list-item', ['page' => $page])
+                    @if(count($pages) > 0)
+                        <div class="page-list">
                             <hr>
-                        @endforeach
-                    </div>
-                @else
-                    <hr>
-                    <p class="text-muted">{{ trans('entities.chapters_empty') }}</p>
-                    <p>
-                        @if(userCan('page-create', $chapter))
-                            <a href="{{ $chapter->getUrl('/create-page') }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.books_empty_create_page') }}</a>
-                        @endif
-                        @if(userCan('page-create', $chapter) && userCan('book-update', $book))
-                            &nbsp;&nbsp;<em class="text-muted">-{{ trans('entities.books_empty_or') }}-</em>&nbsp;&nbsp;&nbsp;
-                        @endif
-                        @if(userCan('book-update', $book))
-                            <a href="{{ $book->getUrl('/sort') }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.books_empty_sort_current_book') }}</a>
-                        @endif
-                    </p>
-                    <hr>
-                @endif
+                            @foreach($pages as $page)
+                                @include('pages/list-item', ['page' => $page])
+                                <hr>
+                            @endforeach
+                        </div>
+                    @else
+                        <hr>
+                        <p class="text-muted">{{ trans('entities.chapters_empty') }}</p>
+                        <p>
+                            @if(userCan('page-create', $chapter))
+                                <a href="{{ $chapter->getUrl('/create-page') }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.books_empty_create_page') }}</a>
+                            @endif
+                            @if(userCan('page-create', $chapter) && userCan('book-update', $book))
+                                &nbsp;&nbsp;<em class="text-muted">-{{ trans('entities.books_empty_or') }}-</em>&nbsp;&nbsp;&nbsp;
+                            @endif
+                            @if(userCan('book-update', $book))
+                                <a href="{{ $book->getUrl('/sort') }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.books_empty_sort_current_book') }}</a>
+                            @endif
+                        </p>
+                        <hr>
+                    @endif
 
-                @include('partials.entity-meta', ['entity' => $chapter])
+                    @include('partials.entity-meta', ['entity' => $chapter])
+                </div>
+
+                <div class="search-results" v-cloak v-if="searching">
+                    <h3 class="text-muted">{{ trans('entities.search_results') }} <a v-if="searching" v-on:click="clearSearch()" class="text-small"><i class="zmdi zmdi-close"></i>{{ trans('entities.search_clear') }}</a></h3>
+                    <div v-if="!searchResults">
+                        @include('partials/loading-icon')
+                    </div>
+                    <div v-html="searchResults"></div>
+                </div>
             </div>
-            <div class="col-md-3 col-md-offset-1">
+            <div class="col-md-4 col-md-offset-1">
                 <div class="margin-top large"></div>
                 @if($book->restricted || $chapter->restricted)
                     <div class="text-muted">
                     </div>
                 @endif
 
+                <div class="search-box">
+                    <form v-on:submit="searchBook">
+                        <input v-model="searchTerm" v-on:change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.chapters_search_this') }}">
+                        <button type="submit"><i class="zmdi zmdi-search"></i></button>
+                        <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button>
+                    </form>
+                </div>
+
                 @include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree])
+
             </div>
         </div>
     </div>
index f366e9e9b8a29c79a9f6563a4437a98a3a1364ad..faae6420a62cf0f72a75e5b845773049026ffc24 100644 (file)
@@ -3,13 +3,13 @@
 
     @if(isset($page) && $page->tags->count() > 0)
         <div class="tag-display">
-            <h6 class="text-muted">Page Tags</h6>
+            <h6 class="text-muted">{{ trans('entities.page_tags') }}</h6>
             <table>
                 <tbody>
                 @foreach($page->tags as $tag)
                     <tr class="tag">
-                        <td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td>
-                        @if($tag->value) <td class="tag-value"><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%3D' . urlencode($tag->value) . '%5D') }}">{{$tag->value}}</a></td> @endif
+                        <td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td>
+                        @if($tag->value) <td class="tag-value"><a href="{{ baseUrl('/search?term=%5B' . urlencode($tag->name) .'%3D' . urlencode($tag->value) . '%5D') }}">{{$tag->value}}</a></td> @endif
                     </tr>
                 @endforeach
                 </tbody>
index d4053752f4dee40f167a6c57a9f6c0b5b7a198f6..1029b65fabea41c320b447ad63e05dba57e79cb7 100644 (file)
 
 @section('content')
 
+    <input type="hidden" name="searchTerm" value="{{$searchTerm}}">
+
+<div id="search-system">
+
     <div class="faded-small toolbar">
         <div class="container">
             <div class="row">
                 <div class="col-sm-12 faded">
                     <div class="breadcrumbs">
-                        <a href="{{ baseUrl("/search/all?term={$searchTerm}") }}" class="text-button"><i class="zmdi zmdi-search"></i>{{ $searchTerm }}</a>
+                        <a href="{{ baseUrl("/search?term=" . urlencode($searchTerm)) }}" class="text-button"><i class="zmdi zmdi-search"></i>{{ trans('entities.search_for_term', ['term' => $searchTerm]) }}</a>
                     </div>
                 </div>
             </div>
         </div>
     </div>
 
+    <div class="container" ng-non-bindable id="searchSystem">
 
-    <div class="container" ng-non-bindable>
+        <div class="row">
 
-        <h1>{{ trans('entities.search_results') }}</h1>
+            <div class="col-md-6">
+                <h1>{{ trans('entities.search_results') }}</h1>
+                <h6 class="text-muted">{{ trans_choice('entities.search_total_results_found', $totalResults, ['count' => $totalResults]) }}</h6>
+                @include('partials/entity-list', ['entities' => $entities])
+                @if ($hasNextPage)
+                    <a href="{{ $nextPageLink }}" class="button">{{ trans('entities.search_more') }}</a>
+                @endif
+            </div>
 
-        <p>
-            @if(count($pages) > 0)
-                <a href="{{ baseUrl("/search/pages?term={$searchTerm}") }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.search_view_pages') }}</a>
-            @endif
+            <div class="col-md-5 col-md-offset-1">
+               <h3>{{ trans('entities.search_filters') }}</h3>
 
-            @if(count($chapters) > 0)
-                &nbsp; &nbsp;&nbsp;
-                <a href="{{ baseUrl("/search/chapters?term={$searchTerm}") }}" class="text-chapter"><i class="zmdi zmdi-collection-bookmark"></i>{{ trans('entities.search_view_chapters') }}</a>
-            @endif
+                <form v-on:submit="updateSearch" v-cloak class="v-cloak anim fadeIn">
+                    <h6 class="text-muted">{{ trans('entities.search_content_type') }}</h6>
+                    <div class="form-group">
+                        <label class="inline checkbox text-page"><input type="checkbox" v-on:change="typeChange" v-model="search.type.page" value="page">{{ trans('entities.page') }}</label>
+                        <label class="inline checkbox text-chapter"><input type="checkbox" v-on:change="typeChange" v-model="search.type.chapter" value="chapter">{{ trans('entities.chapter') }}</label>
+                        <label class="inline checkbox text-book"><input type="checkbox" v-on:change="typeChange" v-model="search.type.book" value="book">{{ trans('entities.book') }}</label>
+                    </div>
 
-            @if(count($books) > 0)
-                &nbsp; &nbsp;&nbsp;
-                <a href="{{ baseUrl("/search/books?term={$searchTerm}") }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.search_view_books') }}</a>
-            @endif
-        </p>
+                    <h6 class="text-muted">{{ trans('entities.search_exact_matches') }}</h6>
+                    <table cellpadding="0" cellspacing="0" border="0" class="no-style">
+                        <tr v-for="(term, i) in search.exactTerms">
+                            <td style="padding: 0 12px 6px 0;">
+                                <input class="exact-input outline" v-on:input="exactChange" type="text" v-model="search.exactTerms[i]"></td>
+                            <td>
+                                <button type="button" class="text-neg text-button" v-on:click="removeExact(i)">
+                                    <i class="zmdi zmdi-close"></i>
+                                </button>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td colspan="2">
+                                <button type="button" class="text-button" v-on:click="addExact">
+                                    <i class="zmdi zmdi-plus-circle-o"></i>{{ trans('common.add') }}
+                                </button>
+                            </td>
+                        </tr>
+                    </table>
+
+                    <h6 class="text-muted">{{ trans('entities.search_tags') }}</h6>
+                    <table cellpadding="0" cellspacing="0" border="0" class="no-style">
+                        <tr v-for="(term, i) in search.tagTerms">
+                            <td style="padding: 0 12px 6px 0;">
+                                <input class="tag-input outline" v-on:input="tagChange" type="text" v-model="search.tagTerms[i]"></td>
+                            <td>
+                                <button type="button" class="text-neg text-button" v-on:click="removeTag(i)">
+                                    <i class="zmdi zmdi-close"></i>
+                                </button>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td colspan="2">
+                                <button type="button" class="text-button" v-on:click="addTag">
+                                    <i class="zmdi zmdi-plus-circle-o"></i>{{ trans('common.add') }}
+                                </button>
+                            </td>
+                        </tr>
+                    </table>
+
+                    <h6 class="text-muted">Options</h6>
+                    <label class="checkbox">
+                        <input type="checkbox" v-on:change="optionChange('viewed_by_me')"
+                               v-model="search.option.viewed_by_me" value="page">
+                        {{ trans('entities.search_viewed_by_me') }}
+                    </label>
+                    <label class="checkbox">
+                        <input type="checkbox" v-on:change="optionChange('not_viewed_by_me')"
+                               v-model="search.option.not_viewed_by_me" value="page">
+                        {{ trans('entities.search_not_viewed_by_me') }}
+                    </label>
+                    <label class="checkbox">
+                        <input type="checkbox" v-on:change="optionChange('is_restricted')"
+                               v-model="search.option.is_restricted" value="page">
+                        {{ trans('entities.search_permissions_set') }}
+                    </label>
+                    <label class="checkbox">
+                        <input type="checkbox" v-on:change="optionChange('created_by:me')"
+                               v-model="search.option['created_by:me']" value="page">
+                        {{ trans('entities.search_created_by_me') }}
+                    </label>
+                    <label class="checkbox">
+                        <input type="checkbox" v-on:change="optionChange('updated_by:me')"
+                               v-model="search.option['updated_by:me']" value="page">
+                        {{ trans('entities.search_updated_by_me') }}
+                    </label>
+
+                    <h6 class="text-muted">Date Options</h6>
+                    <table cellpadding="0" cellspacing="0" border="0" class="no-style form-table">
+                        <tr>
+                            <td width="200">{{ trans('entities.search_updated_after') }}</td>
+                            <td width="80">
+                                <button type="button" class="text-button" v-if="!search.dates.updated_after"
+                                        v-on:click="enableDate('updated_after')">{{ trans('entities.search_set_date') }}</button>
+
+                            </td>
+                        </tr>
+                        <tr v-if="search.dates.updated_after">
+                            <td>
+                                <input v-if="search.dates.updated_after" class="tag-input"
+                                       v-on:input="dateChange('updated_after')" type="date" v-model="search.dates.updated_after"
+                                       pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
+                            </td>
+                            <td>
+                                <button v-if="search.dates.updated_after" type="button" class="text-neg text-button"
+                                        v-on:click="dateRemove('updated_after')">
+                                    <i class="zmdi zmdi-close"></i>
+                                </button>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>{{ trans('entities.search_updated_before') }}</td>
+                            <td>
+                                <button type="button" class="text-button" v-if="!search.dates.updated_before"
+                                        v-on:click="enableDate('updated_before')">{{ trans('entities.search_set_date') }}</button>
+
+                            </td>
+                        </tr>
+                        <tr v-if="search.dates.updated_before">
+                            <td>
+                                <input v-if="search.dates.updated_before" class="tag-input"
+                                       v-on:input="dateChange('updated_before')" type="date" v-model="search.dates.updated_before"
+                                       pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
+                            </td>
+                            <td>
+                                <button v-if="search.dates.updated_before" type="button" class="text-neg text-button"
+                                        v-on:click="dateRemove('updated_before')">
+                                    <i class="zmdi zmdi-close"></i>
+                                </button>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>{{ trans('entities.search_created_after') }}</td>
+                            <td>
+                                <button type="button" class="text-button" v-if="!search.dates.created_after"
+                                        v-on:click="enableDate('created_after')">{{ trans('entities.search_set_date') }}</button>
+
+                            </td>
+                        </tr>
+                        <tr v-if="search.dates.created_after">
+                            <td>
+                                <input v-if="search.dates.created_after" class="tag-input"
+                                       v-on:input="dateChange('created_after')" type="date" v-model="search.dates.created_after"
+                                       pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
+                            </td>
+                            <td>
+                                <button v-if="search.dates.created_after" type="button" class="text-neg text-button"
+                                        v-on:click="dateRemove('created_after')">
+                                    <i class="zmdi zmdi-close"></i>
+                                </button>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>{{ trans('entities.search_created_before') }}</td>
+                            <td>
+                                <button type="button" class="text-button" v-if="!search.dates.created_before"
+                                        v-on:click="enableDate('created_before')">{{ trans('entities.search_set_date') }}</button>
+
+                            </td>
+                        </tr>
+                        <tr v-if="search.dates.created_before">
+                            <td>
+                                <input v-if="search.dates.created_before" class="tag-input"
+                                       v-on:input="dateChange('created_before')" type="date" v-model="search.dates.created_before"
+                                       pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
+                            </td>
+                            <td>
+                                <button v-if="search.dates.created_before" type="button" class="text-neg text-button"
+                                        v-on:click="dateRemove('created_before')">
+                                    <i class="zmdi zmdi-close"></i>
+                                </button>
+                            </td>
+                        </tr>
+                    </table>
+
+
+                    <button type="submit" class="button primary">{{ trans('entities.search_update') }}</button>
+                </form>
 
-        <div class="row">
-            <div class="col-md-6">
-                <h3><a href="{{ baseUrl("/search/pages?term={$searchTerm}") }}" class="no-color">{{ trans('entities.pages') }}</a></h3>
-                @include('partials/entity-list', ['entities' => $pages, 'style' => 'detailed'])
-            </div>
-            <div class="col-md-5 col-md-offset-1">
-                @if(count($books) > 0)
-                    <h3><a href="{{ baseUrl("/search/books?term={$searchTerm}") }}" class="no-color">{{ trans('entities.books') }}</a></h3>
-                    @include('partials/entity-list', ['entities' => $books])
-                @endif
 
-                @if(count($chapters) > 0)
-                    <h3><a href="{{ baseUrl("/search/chapters?term={$searchTerm}") }}" class="no-color">{{ trans('entities.chapters') }}</a></h3>
-                    @include('partials/entity-list', ['entities' => $chapters])
-                @endif
             </div>
+
         </div>
 
 
     </div>
-
+</div>
 
 @stop
\ No newline at end of file
index 8259a633b396170a88004e147b817c11b49bda39..8ecfd9465ec6c2962375248722031e4a2daa3270 100644 (file)
@@ -123,11 +123,9 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/link/{id}', 'PageController@redirectFromLink');
 
     // Search
-    Route::get('/search/all', 'SearchController@searchAll');
-    Route::get('/search/pages', 'SearchController@searchPages');
-    Route::get('/search/books', 'SearchController@searchBooks');
-    Route::get('/search/chapters', 'SearchController@searchChapters');
+    Route::get('/search', 'SearchController@search');
     Route::get('/search/book/{bookId}', 'SearchController@searchBook');
+    Route::get('/search/chapter/{bookId}', 'SearchController@searchChapter');
 
     // Other Pages
     Route::get('/', 'HomeController@index');
index 4ef8d46fbf0643815540633d149d71d402e23629..9f77972c4cd1cc8ec6796e6d817b73ac5864d483 100644 (file)
@@ -1,6 +1,7 @@
 <?php namespace Tests;
 
-class EntitySearchTest extends BrowserKitTest
+
+class EntitySearchTest extends TestCase
 {
 
     public function test_page_search()
@@ -8,91 +9,57 @@ class EntitySearchTest extends BrowserKitTest
         $book = \BookStack\Book::all()->first();
         $page = $book->pages->first();
 
-        $this->asAdmin()
-            ->visit('/')
-            ->type($page->name, 'term')
-            ->press('header-search-box-button')
-            ->see('Search Results')
-            ->seeInElement('.entity-list', $page->name)
-            ->clickInElement('.entity-list', $page->name)
-            ->seePageIs($page->getUrl());
+        $search = $this->asEditor()->get('/search?term=' . urlencode($page->name));
+        $search->assertSee('Search Results');
+        $search->assertSee($page->name);
     }
 
     public function test_invalid_page_search()
     {
-        $this->asAdmin()
-            ->visit('/')
-            ->type('<p>test</p>', 'term')
-            ->press('header-search-box-button')
-            ->see('Search Results')
-            ->seeStatusCode(200);
+        $resp = $this->asEditor()->get('/search?term=' . urlencode('<p>test</p>'));
+        $resp->assertSee('Search Results');
+        $resp->assertStatus(200);
+        $this->get('/search?term=cat+-')->assertStatus(200);
     }
 
-    public function test_empty_search_redirects_back()
+    public function test_empty_search_shows_search_page()
     {
-        $this->asAdmin()
-            ->visit('/')
-            ->visit('/search/all')
-            ->seePageIs('/');
+        $res = $this->asEditor()->get('/search');
+        $res->assertStatus(200);
     }
 
-    public function test_book_search()
+    public function test_searching_accents_and_small_terms()
     {
-        $book = \BookStack\Book::all()->first();
-        $page = $book->pages->last();
-        $chapter = $book->chapters->last();
+        $page = $this->newPage(['name' => 'My new test quaffleachits', 'html' => 'some áéííúü¿¡ test content {a2 orange dog']);
+        $this->asEditor();
 
-        $this->asAdmin()
-            ->visit('/search/book/' . $book->id . '?term=' . urlencode($page->name))
-            ->see($page->name)
+        $accentSearch = $this->get('/search?term=' . urlencode('áéíí'));
+        $accentSearch->assertStatus(200)->assertSee($page->name);
 
-            ->visit('/search/book/' . $book->id  . '?term=' . urlencode($chapter->name))
-            ->see($chapter->name);
+        $smallSearch = $this->get('/search?term=' . urlencode('{a'));
+        $smallSearch->assertStatus(200)->assertSee($page->name);
     }
 
-    public function test_empty_book_search_redirects_back()
+    public function test_book_search()
     {
         $book = \BookStack\Book::all()->first();
-        $this->asAdmin()
-            ->visit('/books')
-            ->visit('/search/book/' . $book->id . '?term=')
-            ->seePageIs('/books');
-    }
-
-
-    public function test_pages_search_listing()
-    {
-        $page = \BookStack\Page::all()->last();
-        $this->asAdmin()->visit('/search/pages?term=' . $page->name)
-            ->see('Page Search Results')->see('.entity-list', $page->name);
-    }
+        $page = $book->pages->last();
+        $chapter = $book->chapters->last();
 
-    public function test_chapters_search_listing()
-    {
-        $chapter = \BookStack\Chapter::all()->last();
-        $this->asAdmin()->visit('/search/chapters?term=' . $chapter->name)
-            ->see('Chapter Search Results')->seeInElement('.entity-list', $chapter->name);
-    }
+        $pageTestResp = $this->asEditor()->get('/search/book/' . $book->id . '?term=' . urlencode($page->name));
+        $pageTestResp->assertSee($page->name);
 
-    public function test_search_quote_term_preparation()
-    {
-        $termString = '"192" cat "dog hat"';
-        $repo = $this->app[\BookStack\Repos\EntityRepo::class];
-        $preparedTerms = $repo->prepareSearchTerms($termString);
-        $this->assertTrue($preparedTerms === ['"192"','"dog hat"', 'cat']);
+        $chapterTestResp = $this->asEditor()->get('/search/book/' . $book->id . '?term=' . urlencode($chapter->name));
+        $chapterTestResp->assertSee($chapter->name);
     }
 
-    public function test_books_search_listing()
+    public function test_chapter_search()
     {
-        $book = \BookStack\Book::all()->last();
-        $this->asAdmin()->visit('/search/books?term=' . $book->name)
-            ->see('Book Search Results')->see('.entity-list', $book->name);
-    }
+        $chapter = \BookStack\Chapter::has('pages')->first();
+        $page = $chapter->pages[0];
 
-    public function test_searching_hypen_doesnt_break()
-    {
-        $this->visit('/search/all?term=cat+-')
-            ->seeStatusCode(200);
+        $pageTestResp = $this->asEditor()->get('/search/chapter/' . $chapter->id . '?term=' . urlencode($page->name));
+        $pageTestResp->assertSee($page->name);
     }
 
     public function test_tag_search()
@@ -114,27 +81,99 @@ class EntitySearchTest extends BrowserKitTest
         $pageB = \BookStack\Page::all()->last();
         $pageB->tags()->create(['name' => 'animal', 'value' => 'dog']);
 
-        $this->asAdmin()->visit('/search/all?term=%5Banimal%5D')
-            ->seeLink($pageA->name)
-            ->seeLink($pageB->name);
+        $this->asEditor();
+        $tNameSearch = $this->get('/search?term=%5Banimal%5D');
+        $tNameSearch->assertSee($pageA->name)->assertSee($pageB->name);
 
-        $this->visit('/search/all?term=%5Bcolor%5D')
-            ->seeLink($pageA->name)
-            ->dontSeeLink($pageB->name);
+        $tNameSearch2 = $this->get('/search?term=%5Bcolor%5D');
+        $tNameSearch2->assertSee($pageA->name)->assertDontSee($pageB->name);
+
+        $tNameValSearch = $this->get('/search?term=%5Banimal%3Dcat%5D');
+        $tNameValSearch->assertSee($pageA->name)->assertDontSee($pageB->name);
+    }
+
+    public function test_exact_searches()
+    {
+        $page = $this->newPage(['name' => 'My new test page', 'html' => 'this is a story about an orange donkey']);
 
-        $this->visit('/search/all?term=%5Banimal%3Dcat%5D')
-            ->seeLink($pageA->name)
-            ->dontSeeLink($pageB->name);
+        $exactSearchA = $this->asEditor()->get('/search?term=' . urlencode('"story about an orange"'));
+        $exactSearchA->assertStatus(200)->assertSee($page->name);
 
+        $exactSearchB = $this->asEditor()->get('/search?term=' . urlencode('"story not about an orange"'));
+        $exactSearchB->assertStatus(200)->assertDontSee($page->name);
+    }
+
+    public function test_search_filters()
+    {
+        $page = $this->newPage(['name' => 'My new test quaffleachits', 'html' => 'this is about an orange donkey danzorbhsing']);
+        $this->asEditor();
+        $editorId = $this->getEditor()->id;
+
+        // Viewed filter searches
+        $this->get('/search?term=' . urlencode('danzorbhsing {not_viewed_by_me}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {viewed_by_me}'))->assertDontSee($page->name);
+        $this->get($page->getUrl());
+        $this->get('/search?term=' . urlencode('danzorbhsing {not_viewed_by_me}'))->assertDontSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {viewed_by_me}'))->assertSee($page->name);
+
+        // User filters
+        $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertDontSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorId.'}'))->assertDontSee($page->name);
+        $page->created_by = $editorId;
+        $page->save();
+        $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {created_by:'.$editorId.'}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name);
+        $page->updated_by = $editorId;
+        $page->save();
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorId.'}'))->assertSee($page->name);
+
+        // Content filters
+        $this->get('/search?term=' . urlencode('{in_name:danzorbhsing}'))->assertDontSee($page->name);
+        $this->get('/search?term=' . urlencode('{in_body:danzorbhsing}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('{in_name:test quaffleachits}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('{in_body:test quaffleachits}'))->assertDontSee($page->name);
+
+        // Restricted filter
+        $this->get('/search?term=' . urlencode('danzorbhsing {is_restricted}'))->assertDontSee($page->name);
+        $page->restricted = true;
+        $page->save();
+        $this->get('/search?term=' . urlencode('danzorbhsing {is_restricted}'))->assertSee($page->name);
+
+        // Date filters
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_after:2037-01-01}'))->assertDontSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_before:2037-01-01}'))->assertSee($page->name);
+        $page->updated_at = '2037-02-01';
+        $page->save();
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_after:2037-01-01}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_before:2037-01-01}'))->assertDontSee($page->name);
+
+        $this->get('/search?term=' . urlencode('danzorbhsing {created_after:2037-01-01}'))->assertDontSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {created_before:2037-01-01}'))->assertSee($page->name);
+        $page->created_at = '2037-02-01';
+        $page->save();
+        $this->get('/search?term=' . urlencode('danzorbhsing {created_after:2037-01-01}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {created_before:2037-01-01}'))->assertDontSee($page->name);
     }
 
     public function test_ajax_entity_search()
     {
         $page = \BookStack\Page::all()->last();
         $notVisitedPage = \BookStack\Page::first();
-        $this->visit($page->getUrl());
-        $this->asAdmin()->visit('/ajax/search/entities?term=' . $page->name)->see('.entity-list', $page->name);
-        $this->asAdmin()->visit('/ajax/search/entities?types=book&term=' . $page->name)->dontSee('.entity-list', $page->name);
-        $this->asAdmin()->visit('/ajax/search/entities')->see('.entity-list', $page->name)->dontSee($notVisitedPage->name);
+
+        // Visit the page to make popular
+        $this->asEditor()->get($page->getUrl());
+
+        $normalSearch = $this->get('/ajax/search/entities?term=' . urlencode($page->name));
+        $normalSearch->assertSee($page->name);
+
+        $bookSearch = $this->get('/ajax/search/entities?types=book&term=' . urlencode($page->name));
+        $bookSearch->assertDontSee($page->name);
+
+        $defaultListTest = $this->get('/ajax/search/entities');
+        $defaultListTest->assertSee($page->name);
+        $defaultListTest->assertDontSee($notVisitedPage->name);
     }
 }
index f3f36ca1cc75daa3211c82d0ee5eec2628891b33..b008080d9c3ed92cad3976e6c554628facbb48a3 100644 (file)
@@ -76,4 +76,16 @@ abstract class TestCase extends BaseTestCase
     public function newChapter($input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book) {
         return $this->app[EntityRepo::class]->createFromInput('chapter', $input, $book);
     }
+
+    /**
+     * Create and return a new test page
+     * @param array $input
+     * @return Chapter
+     */
+    public function newPage($input = ['name' => 'test page', 'html' => 'My new test page']) {
+        $book = Book::first();
+        $entityRepo = $this->app[EntityRepo::class];
+        $draftPage = $entityRepo->getDraftPage($book);
+        return $entityRepo->publishPageDraft($draftPage, $input);
+    }
 }
\ No newline at end of file