]> BookStack Code Mirror - bookstack/commitdiff
Updated Search experience including adding fulltext mysql indicies.
authorDan Brown <redacted>
Mon, 31 Aug 2015 19:11:44 +0000 (20:11 +0100)
committerDan Brown <redacted>
Mon, 31 Aug 2015 19:11:44 +0000 (20:11 +0100)
15 files changed:
app/Book.php
app/Entity.php
app/Http/Controllers/PageController.php
app/Http/Controllers/SearchController.php [new file with mode: 0644]
app/Http/routes.php
app/Repos/BookRepo.php
app/Repos/ChapterRepo.php
app/Repos/PageRepo.php
app/Services/SettingService.php
database/migrations/2015_08_31_175240_add_search_indexes.php [new file with mode: 0644]
resources/assets/sass/_text.scss
resources/assets/sass/styles.scss
resources/views/base.blade.php
resources/views/pages/search-results.blade.php [deleted file]
resources/views/search/all.blade.php [new file with mode: 0644]

index 8a4be213f5c73681a1a8a522880cff689283ebd3..c6e5f60a74052e8cfca9d6ab05fce37dfdd428b8 100644 (file)
@@ -37,4 +37,9 @@ class Book extends Entity
         return $pages->sortBy('priority');
     }
 
+    public function getExcerpt($length = 100)
+    {
+        return strlen($this->description) > $length ? substr($this->description, 0, $length-3) . '...' : $this->description;
+    }
+
 }
index 7c08cee905d5c0d8d89a4a2883b867b2f2c0db0f..0fd5d1e12e54515ec9aa07c59d653e9affaa7831 100644 (file)
@@ -55,10 +55,30 @@ class Entity extends Model
         return $this->getName() === strtolower($type);
     }
 
+    /**
+     * Gets the class name.
+     * @return string
+     */
     public function getName()
     {
         $fullClassName = get_class($this);
         return strtolower(array_slice(explode('\\', $fullClassName), -1, 1)[0]);
     }
 
+    /**
+     * Perform a full-text search on this entity.
+     * @param string[] $fieldsToSearch
+     * @param string[] $terms
+     * @return mixed
+     */
+    public static function fullTextSearch($fieldsToSearch, $terms)
+    {
+        $termString = '';
+        foreach($terms as $term) {
+            $termString .= $term . '* ';
+        }
+        $fields = implode(',', $fieldsToSearch);
+        return static::whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString])->get();
+    }
+
 }
index b59dd2446419ce6788efa6538eae077b7c02061f..4bcdbb80b84090d7a98c26833b5f7b9b65ec0b6c 100644 (file)
@@ -142,20 +142,6 @@ class PageController extends Controller
         return redirect($page->getUrl());
     }
 
-    /**
-     * Search all available pages, Across all books.
-     * @param Request $request
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View
-     */
-    public function searchAll(Request $request)
-    {
-        $searchTerm = $request->get('term');
-        if (empty($searchTerm)) return redirect()->back();
-
-        $pages = $this->pageRepo->getBySearch($searchTerm);
-        return view('pages/search-results', ['pages' => $pages, 'searchTerm' => $searchTerm]);
-    }
-
     /**
      * Shows the view which allows pages to be re-ordered and sorted.
      * @param $bookSlug
diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php
new file mode 100644 (file)
index 0000000..074a4c7
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+namespace Oxbow\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+use Oxbow\Http\Requests;
+use Oxbow\Http\Controllers\Controller;
+use Oxbow\Repos\BookRepo;
+use Oxbow\Repos\ChapterRepo;
+use Oxbow\Repos\PageRepo;
+
+class SearchController extends Controller
+{
+    protected $pageRepo;
+    protected $bookRepo;
+    protected $chapterRepo;
+
+    /**
+     * SearchController constructor.
+     * @param $pageRepo
+     * @param $bookRepo
+     * @param $chapterRepo
+     */
+    public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo)
+    {
+        $this->pageRepo = $pageRepo;
+        $this->bookRepo = $bookRepo;
+        $this->chapterRepo = $chapterRepo;
+        parent::__construct();
+    }
+
+    /**
+     * Searches all entities.
+     * @param Request $request
+     * @return \Illuminate\View\View
+     * @internal param string $searchTerm
+     */
+    public function searchAll(Request $request)
+    {
+        if(!$request->has('term')) {
+            return redirect()->back();
+        }
+        $searchTerm = $request->get('term');
+        $pages = $this->pageRepo->getBySearch($searchTerm);
+        $books = $this->bookRepo->getBySearch($searchTerm);
+        $chapters = $this->chapterRepo->getBySearch($searchTerm);
+        return view('search/all', ['pages' => $pages, 'books'=>$books, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
+    }
+
+
+}
index 2dfd35abd261403633ee9fc9a98650b04d9b66ab..c7ff66f215678eafeef4851f7a2905ef8498dea7 100644 (file)
@@ -65,7 +65,7 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/link/{id}', 'PageController@redirectFromLink');
 
     // Search
-    Route::get('/pages/search/all', 'PageController@searchAll');
+    Route::get('/search/all', 'SearchController@searchAll');
 
     // Other Pages
     Route::get('/', 'HomeController@index');
index 488478aac5daf58dbae57df69a22dda29b1033c0..5ddf0b1ef9565481784801c759d4869a6855d70d 100644 (file)
@@ -81,4 +81,17 @@ class BookRepo
         return $slug;
     }
 
+    public function getBySearch($term)
+    {
+        $terms = explode(' ', preg_quote(trim($term)));
+        $books = $this->book->fullTextSearch(['name', 'description'], $terms);
+        $words = join('|', $terms);
+        foreach ($books as $book) {
+            //highlight
+            $result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $book->getExcerpt(100));
+            $book->searchSnippet = $result;
+        }
+        return $books;
+    }
+
 }
\ No newline at end of file
index 1eb819a82cbc0c5f0ae4e187b167af38dc05a35e..0dcaa5fbdf6e755b2692b158a26399084e04a7cc 100644 (file)
@@ -67,4 +67,17 @@ class ChapterRepo
         return $slug;
     }
 
+    public function getBySearch($term)
+    {
+        $terms = explode(' ', preg_quote(trim($term)));
+        $chapters = $this->chapter->fullTextSearch(['name', 'description'], $terms);
+        $words = join('|', $terms);
+        foreach ($chapters as $chapter) {
+            //highlight
+            $result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $chapter->getExcerpt(100));
+            $chapter->searchSnippet = $result;
+        }
+        return $chapters;
+    }
+
 }
\ No newline at end of file
index 52620961f33c202ef1ac95c1cad3616eb8f5ee82..51a3e8ce9f698ed85b6944e265053f31e05052e2 100644 (file)
@@ -13,7 +13,7 @@ class PageRepo
 
     /**
      * PageRepo constructor.
-     * @param Page $page
+     * @param Page         $page
      * @param PageRevision $pageRevision
      */
     public function __construct(Page $page, PageRevision $pageRevision)
@@ -61,19 +61,42 @@ class PageRepo
 
     public function getBySearch($term)
     {
-        $terms = explode(' ', trim($term));
-        $query = $this->page;
-        foreach($terms as $term) {
-            $query = $query->where('text', 'like', '%'.$term.'%');
+        $terms = explode(' ', preg_quote(trim($term)));
+        $pages = $this->page->fullTextSearch(['name', 'text'], $terms);
+
+        // Add highlights to page text.
+        $words = join('|', $terms);
+        //lookahead/behind assertions ensures cut between words
+        $s = '\s\x00-/:-@\[-`{-~'; //character set for start/end of words
+
+        foreach ($pages 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 $query->get();
+        return $pages;
     }
 
     /**
      * Updates a page with any fillable data and saves it into the database.
      * @param Page $page
-     * @param $book_id
-     * @param $data
+     * @param      $book_id
+     * @param      $data
      * @return Page
      */
     public function updatePage(Page $page, $book_id, $data)
@@ -95,7 +118,7 @@ class PageRepo
     public function saveRevision(Page $page)
     {
         $lastRevision = $this->getLastRevision($page);
-        if($lastRevision && ($lastRevision->html === $page->html && $lastRevision->name === $page->name)) {
+        if ($lastRevision && ($lastRevision->html === $page->html && $lastRevision->name === $page->name)) {
             return $page;
         }
         $revision = $this->pageRevision->fill($page->toArray());
@@ -103,7 +126,7 @@ class PageRepo
         $revision->created_by = Auth::user()->id;
         $revision->save();
         // Clear old revisions
-        if($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) {
+        if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) {
             $this->pageRevision->where('page_id', '=', $page->id)
                 ->orderBy('created_at', 'desc')->skip(50)->take(5)->delete();
         }
@@ -133,15 +156,15 @@ class PageRepo
 
     /**
      * Checks if a slug exists within a book already.
-     * @param $slug
-     * @param $bookId
+     * @param            $slug
+     * @param            $bookId
      * @param bool|false $currentId
      * @return bool
      */
     public function doesSlugExist($slug, $bookId, $currentId = false)
     {
         $query = $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId);
-        if($currentId) {
+        if ($currentId) {
             $query = $query->where('id', '!=', $currentId);
         }
         return $query->count() > 0;
@@ -150,15 +173,15 @@ class PageRepo
     /**
      * Gets a suitable slug for the resource
      *
-     * @param $name
-     * @param $bookId
+     * @param            $name
+     * @param            $bookId
      * @param bool|false $currentId
      * @return string
      */
     public function findSuitableSlug($name, $bookId, $currentId = false)
     {
         $slug = Str::slug($name);
-        while($this->doesSlugExist($slug, $bookId, $currentId)) {
+        while ($this->doesSlugExist($slug, $bookId, $currentId)) {
             $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
         }
         return $slug;
index 4c27035fd7df6991c4f3121df9e2d598cddf35b9..46c802a05763999108cc0e2c45656b7d6e58e797 100644 (file)
@@ -71,7 +71,7 @@ class SettingService
     public function remove($key)
     {
         $setting = $this->getSettingObjectByKey($key);
-        if($setting) {
+        if ($setting) {
             $setting->delete();
         }
         return true;
@@ -82,7 +82,8 @@ class SettingService
      * @param $key
      * @return mixed
      */
-    private function getSettingObjectByKey($key) {
+    private function getSettingObjectByKey($key)
+    {
         return $this->setting->where('setting_key', '=', $key)->first();
     }
 
diff --git a/database/migrations/2015_08_31_175240_add_search_indexes.php b/database/migrations/2015_08_31_175240_add_search_indexes.php
new file mode 100644 (file)
index 0000000..99e5a28
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddSearchIndexes extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    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)');
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('pages', function(Blueprint $table) {
+            $table->dropIndex('search');
+        });
+        Schema::table('books', function(Blueprint $table) {
+            $table->dropIndex('search');
+        });
+        Schema::table('chapters', function(Blueprint $table) {
+            $table->dropIndex('search');
+        });
+    }
+}
index 6a63f1e5e33b45a449752d046332a274bd93565d..7015400a9048c15513bdfb5579c9ca64effeb2f6 100644 (file)
@@ -211,6 +211,12 @@ p.secondary, p .secondary, span.secondary, .text-secondary {
   }
 }
 
+span.highlight {
+  //background-color: rgba($primary, 0.2);
+  font-weight: bold;
+  //padding: 2px 4px;
+}
+
 /*
  * Lists
  */
index 68b1c012537ab4fb4bc172d9cbc7a57711395473..0101aed9f25d3203e61f6905c658de927da43b4f 100644 (file)
@@ -36,9 +36,6 @@ header {
       padding-right: 0;
     }
   }
-  .search-box {
-    padding-top: $-l *0.8;
-  }
   .avatar, .user-name {
     display: inline-block;
   }
@@ -59,6 +56,23 @@ header {
   }
 }
 
+form.search-box {
+  padding-top: $-l *0.9;
+  display: inline-block;
+  input {
+    background-color: transparent;
+    border-radius: 0;
+    border: none;
+    border-bottom: 2px solid #EEE;
+    color: #EEE;
+    padding-left: $-l;
+    outline: 0;
+  }
+  i {
+    margin-right: -$-l;
+  }
+}
+
 #content {
   display: block;
   position: relative;
index 2d3e54ae13cbf77ee80e2362db4cd850f90afa82..f20126b372b327539909f2e2acdd42a735c2c6ee 100644 (file)
                 <div class="col-md-3">
                     <a href="/" class="logo">{{ Setting::get('app-name', 'BookStack') }}</a>
                 </div>
-                <div class="col-md-9">
+                <div class="col-md-3 text-right">
+                    <form action="/search/all" method="GET" class="search-box">
+                        <i class="zmdi zmdi-search"></i>
+                        <input type="text" name="term" tabindex="2" value="{{ isset($searchTerm) ? $searchTerm : '' }}">
+                    </form>
+                </div>
+                <div class="col-md-6">
                     <div class="float right">
                         <div class="links text-center">
-                            <a href="/search"><i class="zmdi zmdi-search"></i></a>
                             <a href="/books"><i class="zmdi zmdi-book"></i>Books</a>
                             @if($currentUser->can('settings-update'))
                                 <a href="/settings"><i class="zmdi zmdi-settings"></i>Settings</a>
diff --git a/resources/views/pages/search-results.blade.php b/resources/views/pages/search-results.blade.php
deleted file mode 100644 (file)
index cf3066d..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-@extends('base')
-
-@section('content')
-
-
-    <div class="row">
-
-        <div class="col-md-3 page-menu">
-
-        </div>
-
-        <div class="col-md-9 page-content">
-            <h1>Search Results <span class="subheader">For '{{$searchTerm}}'</span></h1>
-            <div class="page-list">
-                @if(count($pages) > 0)
-                    @foreach($pages as $page)
-                        <a href="{{$page->getUrl() . '#' . $searchTerm}}">{{$page->name}}</a>
-                    @endforeach
-                @else
-                    <p class="text-muted">No pages matched this search</p>
-                @endif
-            </div>
-        </div>
-
-    </div>
-
-
-
-
-@stop
\ No newline at end of file
diff --git a/resources/views/search/all.blade.php b/resources/views/search/all.blade.php
new file mode 100644 (file)
index 0000000..f2c922d
--- /dev/null
@@ -0,0 +1,85 @@
+@extends('base')
+
+@section('content')
+
+    <div class="container">
+
+        <h1>Search Results&nbsp;&nbsp;&nbsp; <span class="text-muted">{{$searchTerm}}</span></h1>
+
+        <div class="row">
+
+            <div class="col-md-6">
+                <h3>Matching Pages</h3>
+                <div class="page-list">
+                    @if(count($pages) > 0)
+                        @foreach($pages as $page)
+                            <div class="book-child">
+                                <h3>
+                                    <a href="{{$page->getUrl() . '#' . $searchTerm}}" class="page">
+                                        <i class="zmdi zmdi-file-text"></i>{{$page->name}}
+                                    </a>
+                                </h3>
+                                <p class="text-muted">
+                                    {!! $page->searchSnippet !!}
+                                </p>
+                                <hr>
+                            </div>
+                        @endforeach
+                    @else
+                        <p class="text-muted">No pages matched this search</p>
+                    @endif
+                </div>
+            </div>
+
+            <div class="col-md-5 col-md-offset-1">
+
+                @if(count($books) > 0)
+                    <h3>Matching Books</h3>
+                    <div class="page-list">
+                        @foreach($books as $book)
+                            <div class="book-child">
+                                <h3>
+                                    <a href="{{$book->getUrl()}}" class="text-book">
+                                        <i class="zmdi zmdi-book"></i>{{$book->name}}
+                                    </a>
+                                </h3>
+                                <p class="text-muted">
+                                    {!! $book->searchSnippet !!}
+                                </p>
+                                <hr>
+                            </div>
+                        @endforeach
+                    </div>
+                @endif
+
+                @if(count($chapters) > 0)
+                    <h3>Matching Chapters</h3>
+                    <div class="page-list">
+                        @foreach($chapters as $chapter)
+                            <div class="book-child">
+                                <h3>
+                                    <a href="{{$chapter->getUrl()}}" class="text-chapter">
+                                        <i class="zmdi zmdi-collection-bookmark"></i>{{$chapter->name}}
+                                    </a>
+                                </h3>
+                                <p class="text-muted">
+                                    {!! $chapter->searchSnippet !!}
+                                </p>
+                                <hr>
+                            </div>
+                        @endforeach
+                    </div>
+                @endif
+
+            </div>
+
+
+        </div>
+
+
+    </div>
+
+
+
+
+@stop
\ No newline at end of file
Morty Proxy This is a proxified and sanitized view of the page, visit original site.