Dan Brown

Updated Search experience including adding fulltext mysql indicies.

...@@ -37,4 +37,9 @@ class Book extends Entity ...@@ -37,4 +37,9 @@ class Book extends Entity
37 return $pages->sortBy('priority'); 37 return $pages->sortBy('priority');
38 } 38 }
39 39
40 + public function getExcerpt($length = 100)
41 + {
42 + return strlen($this->description) > $length ? substr($this->description, 0, $length-3) . '...' : $this->description;
43 + }
44 +
40 } 45 }
......
...@@ -55,10 +55,30 @@ class Entity extends Model ...@@ -55,10 +55,30 @@ class Entity extends Model
55 return $this->getName() === strtolower($type); 55 return $this->getName() === strtolower($type);
56 } 56 }
57 57
58 + /**
59 + * Gets the class name.
60 + * @return string
61 + */
58 public function getName() 62 public function getName()
59 { 63 {
60 $fullClassName = get_class($this); 64 $fullClassName = get_class($this);
61 return strtolower(array_slice(explode('\\', $fullClassName), -1, 1)[0]); 65 return strtolower(array_slice(explode('\\', $fullClassName), -1, 1)[0]);
62 } 66 }
63 67
68 + /**
69 + * Perform a full-text search on this entity.
70 + * @param string[] $fieldsToSearch
71 + * @param string[] $terms
72 + * @return mixed
73 + */
74 + public static function fullTextSearch($fieldsToSearch, $terms)
75 + {
76 + $termString = '';
77 + foreach($terms as $term) {
78 + $termString .= $term . '* ';
79 + }
80 + $fields = implode(',', $fieldsToSearch);
81 + return static::whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString])->get();
82 + }
83 +
64 } 84 }
......
...@@ -143,20 +143,6 @@ class PageController extends Controller ...@@ -143,20 +143,6 @@ class PageController extends Controller
143 } 143 }
144 144
145 /** 145 /**
146 - * Search all available pages, Across all books.
147 - * @param Request $request
148 - * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View
149 - */
150 - public function searchAll(Request $request)
151 - {
152 - $searchTerm = $request->get('term');
153 - if (empty($searchTerm)) return redirect()->back();
154 -
155 - $pages = $this->pageRepo->getBySearch($searchTerm);
156 - return view('pages/search-results', ['pages' => $pages, 'searchTerm' => $searchTerm]);
157 - }
158 -
159 - /**
160 * Shows the view which allows pages to be re-ordered and sorted. 146 * Shows the view which allows pages to be re-ordered and sorted.
161 * @param $bookSlug 147 * @param $bookSlug
162 * @return \Illuminate\View\View 148 * @return \Illuminate\View\View
......
1 +<?php
2 +
3 +namespace Oxbow\Http\Controllers;
4 +
5 +use Illuminate\Http\Request;
6 +
7 +use Oxbow\Http\Requests;
8 +use Oxbow\Http\Controllers\Controller;
9 +use Oxbow\Repos\BookRepo;
10 +use Oxbow\Repos\ChapterRepo;
11 +use Oxbow\Repos\PageRepo;
12 +
13 +class SearchController extends Controller
14 +{
15 + protected $pageRepo;
16 + protected $bookRepo;
17 + protected $chapterRepo;
18 +
19 + /**
20 + * SearchController constructor.
21 + * @param $pageRepo
22 + * @param $bookRepo
23 + * @param $chapterRepo
24 + */
25 + public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo)
26 + {
27 + $this->pageRepo = $pageRepo;
28 + $this->bookRepo = $bookRepo;
29 + $this->chapterRepo = $chapterRepo;
30 + parent::__construct();
31 + }
32 +
33 + /**
34 + * Searches all entities.
35 + * @param Request $request
36 + * @return \Illuminate\View\View
37 + * @internal param string $searchTerm
38 + */
39 + public function searchAll(Request $request)
40 + {
41 + if(!$request->has('term')) {
42 + return redirect()->back();
43 + }
44 + $searchTerm = $request->get('term');
45 + $pages = $this->pageRepo->getBySearch($searchTerm);
46 + $books = $this->bookRepo->getBySearch($searchTerm);
47 + $chapters = $this->chapterRepo->getBySearch($searchTerm);
48 + return view('search/all', ['pages' => $pages, 'books'=>$books, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
49 + }
50 +
51 +
52 +}
...@@ -65,7 +65,7 @@ Route::group(['middleware' => 'auth'], function () { ...@@ -65,7 +65,7 @@ Route::group(['middleware' => 'auth'], function () {
65 Route::get('/link/{id}', 'PageController@redirectFromLink'); 65 Route::get('/link/{id}', 'PageController@redirectFromLink');
66 66
67 // Search 67 // Search
68 - Route::get('/pages/search/all', 'PageController@searchAll'); 68 + Route::get('/search/all', 'SearchController@searchAll');
69 69
70 // Other Pages 70 // Other Pages
71 Route::get('/', 'HomeController@index'); 71 Route::get('/', 'HomeController@index');
......
...@@ -81,4 +81,17 @@ class BookRepo ...@@ -81,4 +81,17 @@ class BookRepo
81 return $slug; 81 return $slug;
82 } 82 }
83 83
84 + public function getBySearch($term)
85 + {
86 + $terms = explode(' ', preg_quote(trim($term)));
87 + $books = $this->book->fullTextSearch(['name', 'description'], $terms);
88 + $words = join('|', $terms);
89 + foreach ($books as $book) {
90 + //highlight
91 + $result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $book->getExcerpt(100));
92 + $book->searchSnippet = $result;
93 + }
94 + return $books;
95 + }
96 +
84 } 97 }
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -67,4 +67,17 @@ class ChapterRepo ...@@ -67,4 +67,17 @@ class ChapterRepo
67 return $slug; 67 return $slug;
68 } 68 }
69 69
70 + public function getBySearch($term)
71 + {
72 + $terms = explode(' ', preg_quote(trim($term)));
73 + $chapters = $this->chapter->fullTextSearch(['name', 'description'], $terms);
74 + $words = join('|', $terms);
75 + foreach ($chapters as $chapter) {
76 + //highlight
77 + $result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $chapter->getExcerpt(100));
78 + $chapter->searchSnippet = $result;
79 + }
80 + return $chapters;
81 + }
82 +
70 } 83 }
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -61,12 +61,35 @@ class PageRepo ...@@ -61,12 +61,35 @@ class PageRepo
61 61
62 public function getBySearch($term) 62 public function getBySearch($term)
63 { 63 {
64 - $terms = explode(' ', trim($term)); 64 + $terms = explode(' ', preg_quote(trim($term)));
65 - $query = $this->page; 65 + $pages = $this->page->fullTextSearch(['name', 'text'], $terms);
66 - foreach($terms as $term) { 66 +
67 - $query = $query->where('text', 'like', '%'.$term.'%'); 67 + // Add highlights to page text.
68 + $words = join('|', $terms);
69 + //lookahead/behind assertions ensures cut between words
70 + $s = '\s\x00-/:-@\[-`{-~'; //character set for start/end of words
71 +
72 + foreach ($pages as $page) {
73 + preg_match_all('#(?<=[' . $s . ']).{1,30}((' . $words . ').{1,30})+(?=[' . $s . '])#uis', $page->text, $matches, PREG_SET_ORDER);
74 + //delimiter between occurrences
75 + $results = [];
76 + foreach ($matches as $line) {
77 + $results[] = htmlspecialchars($line[0], 0, 'UTF-8');
78 + }
79 + $matchLimit = 6;
80 + if (count($results) > $matchLimit) {
81 + $results = array_slice($results, 0, $matchLimit);
82 + }
83 + $result = join('... ', $results);
84 +
85 + //highlight
86 + $result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $result);
87 + if (strlen($result) < 5) {
88 + $result = $page->getExcerpt(80);
89 + }
90 + $page->searchSnippet = $result;
68 } 91 }
69 - return $query->get(); 92 + return $pages;
70 } 93 }
71 94
72 /** 95 /**
...@@ -95,7 +118,7 @@ class PageRepo ...@@ -95,7 +118,7 @@ class PageRepo
95 public function saveRevision(Page $page) 118 public function saveRevision(Page $page)
96 { 119 {
97 $lastRevision = $this->getLastRevision($page); 120 $lastRevision = $this->getLastRevision($page);
98 - if($lastRevision && ($lastRevision->html === $page->html && $lastRevision->name === $page->name)) { 121 + if ($lastRevision && ($lastRevision->html === $page->html && $lastRevision->name === $page->name)) {
99 return $page; 122 return $page;
100 } 123 }
101 $revision = $this->pageRevision->fill($page->toArray()); 124 $revision = $this->pageRevision->fill($page->toArray());
...@@ -103,7 +126,7 @@ class PageRepo ...@@ -103,7 +126,7 @@ class PageRepo
103 $revision->created_by = Auth::user()->id; 126 $revision->created_by = Auth::user()->id;
104 $revision->save(); 127 $revision->save();
105 // Clear old revisions 128 // Clear old revisions
106 - if($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) { 129 + if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) {
107 $this->pageRevision->where('page_id', '=', $page->id) 130 $this->pageRevision->where('page_id', '=', $page->id)
108 ->orderBy('created_at', 'desc')->skip(50)->take(5)->delete(); 131 ->orderBy('created_at', 'desc')->skip(50)->take(5)->delete();
109 } 132 }
...@@ -141,7 +164,7 @@ class PageRepo ...@@ -141,7 +164,7 @@ class PageRepo
141 public function doesSlugExist($slug, $bookId, $currentId = false) 164 public function doesSlugExist($slug, $bookId, $currentId = false)
142 { 165 {
143 $query = $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId); 166 $query = $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId);
144 - if($currentId) { 167 + if ($currentId) {
145 $query = $query->where('id', '!=', $currentId); 168 $query = $query->where('id', '!=', $currentId);
146 } 169 }
147 return $query->count() > 0; 170 return $query->count() > 0;
...@@ -158,7 +181,7 @@ class PageRepo ...@@ -158,7 +181,7 @@ class PageRepo
158 public function findSuitableSlug($name, $bookId, $currentId = false) 181 public function findSuitableSlug($name, $bookId, $currentId = false)
159 { 182 {
160 $slug = Str::slug($name); 183 $slug = Str::slug($name);
161 - while($this->doesSlugExist($slug, $bookId, $currentId)) { 184 + while ($this->doesSlugExist($slug, $bookId, $currentId)) {
162 $slug .= '-' . substr(md5(rand(1, 500)), 0, 3); 185 $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
163 } 186 }
164 return $slug; 187 return $slug;
......
...@@ -71,7 +71,7 @@ class SettingService ...@@ -71,7 +71,7 @@ class SettingService
71 public function remove($key) 71 public function remove($key)
72 { 72 {
73 $setting = $this->getSettingObjectByKey($key); 73 $setting = $this->getSettingObjectByKey($key);
74 - if($setting) { 74 + if ($setting) {
75 $setting->delete(); 75 $setting->delete();
76 } 76 }
77 return true; 77 return true;
...@@ -82,7 +82,8 @@ class SettingService ...@@ -82,7 +82,8 @@ class SettingService
82 * @param $key 82 * @param $key
83 * @return mixed 83 * @return mixed
84 */ 84 */
85 - private function getSettingObjectByKey($key) { 85 + private function getSettingObjectByKey($key)
86 + {
86 return $this->setting->where('setting_key', '=', $key)->first(); 87 return $this->setting->where('setting_key', '=', $key)->first();
87 } 88 }
88 89
......
1 +<?php
2 +
3 +use Illuminate\Database\Schema\Blueprint;
4 +use Illuminate\Database\Migrations\Migration;
5 +
6 +class AddSearchIndexes extends Migration
7 +{
8 + /**
9 + * Run the migrations.
10 + *
11 + * @return void
12 + */
13 + public function up()
14 + {
15 + DB::statement('ALTER TABLE pages ADD FULLTEXT search(name, text)');
16 + DB::statement('ALTER TABLE books ADD FULLTEXT search(name, description)');
17 + DB::statement('ALTER TABLE chapters ADD FULLTEXT search(name, description)');
18 + }
19 +
20 + /**
21 + * Reverse the migrations.
22 + *
23 + * @return void
24 + */
25 + public function down()
26 + {
27 + Schema::table('pages', function(Blueprint $table) {
28 + $table->dropIndex('search');
29 + });
30 + Schema::table('books', function(Blueprint $table) {
31 + $table->dropIndex('search');
32 + });
33 + Schema::table('chapters', function(Blueprint $table) {
34 + $table->dropIndex('search');
35 + });
36 + }
37 +}
...@@ -211,6 +211,12 @@ p.secondary, p .secondary, span.secondary, .text-secondary { ...@@ -211,6 +211,12 @@ p.secondary, p .secondary, span.secondary, .text-secondary {
211 } 211 }
212 } 212 }
213 213
214 +span.highlight {
215 + //background-color: rgba($primary, 0.2);
216 + font-weight: bold;
217 + //padding: 2px 4px;
218 +}
219 +
214 /* 220 /*
215 * Lists 221 * Lists
216 */ 222 */
......
...@@ -36,9 +36,6 @@ header { ...@@ -36,9 +36,6 @@ header {
36 padding-right: 0; 36 padding-right: 0;
37 } 37 }
38 } 38 }
39 - .search-box {
40 - padding-top: $-l *0.8;
41 - }
42 .avatar, .user-name { 39 .avatar, .user-name {
43 display: inline-block; 40 display: inline-block;
44 } 41 }
...@@ -59,6 +56,23 @@ header { ...@@ -59,6 +56,23 @@ header {
59 } 56 }
60 } 57 }
61 58
59 +form.search-box {
60 + padding-top: $-l *0.9;
61 + display: inline-block;
62 + input {
63 + background-color: transparent;
64 + border-radius: 0;
65 + border: none;
66 + border-bottom: 2px solid #EEE;
67 + color: #EEE;
68 + padding-left: $-l;
69 + outline: 0;
70 + }
71 + i {
72 + margin-right: -$-l;
73 + }
74 +}
75 +
62 #content { 76 #content {
63 display: block; 77 display: block;
64 position: relative; 78 position: relative;
......
...@@ -55,10 +55,15 @@ ...@@ -55,10 +55,15 @@
55 <div class="col-md-3"> 55 <div class="col-md-3">
56 <a href="/" class="logo">{{ Setting::get('app-name', 'BookStack') }}</a> 56 <a href="/" class="logo">{{ Setting::get('app-name', 'BookStack') }}</a>
57 </div> 57 </div>
58 - <div class="col-md-9"> 58 + <div class="col-md-3 text-right">
59 + <form action="/search/all" method="GET" class="search-box">
60 + <i class="zmdi zmdi-search"></i>
61 + <input type="text" name="term" tabindex="2" value="{{ isset($searchTerm) ? $searchTerm : '' }}">
62 + </form>
63 + </div>
64 + <div class="col-md-6">
59 <div class="float right"> 65 <div class="float right">
60 <div class="links text-center"> 66 <div class="links text-center">
61 - <a href="/search"><i class="zmdi zmdi-search"></i></a>
62 <a href="/books"><i class="zmdi zmdi-book"></i>Books</a> 67 <a href="/books"><i class="zmdi zmdi-book"></i>Books</a>
63 @if($currentUser->can('settings-update')) 68 @if($currentUser->can('settings-update'))
64 <a href="/settings"><i class="zmdi zmdi-settings"></i>Settings</a> 69 <a href="/settings"><i class="zmdi zmdi-settings"></i>Settings</a>
......
1 -@extends('base')
2 -
3 -@section('content')
4 -
5 -
6 - <div class="row">
7 -
8 - <div class="col-md-3 page-menu">
9 -
10 - </div>
11 -
12 - <div class="col-md-9 page-content">
13 - <h1>Search Results <span class="subheader">For '{{$searchTerm}}'</span></h1>
14 - <div class="page-list">
15 - @if(count($pages) > 0)
16 - @foreach($pages as $page)
17 - <a href="{{$page->getUrl() . '#' . $searchTerm}}">{{$page->name}}</a>
18 - @endforeach
19 - @else
20 - <p class="text-muted">No pages matched this search</p>
21 - @endif
22 - </div>
23 - </div>
24 -
25 - </div>
26 -
27 -
28 -
29 -
30 -@stop
...\ No newline at end of file ...\ No newline at end of file
1 +@extends('base')
2 +
3 +@section('content')
4 +
5 + <div class="container">
6 +
7 + <h1>Search Results&nbsp;&nbsp;&nbsp; <span class="text-muted">{{$searchTerm}}</span></h1>
8 +
9 + <div class="row">
10 +
11 + <div class="col-md-6">
12 + <h3>Matching Pages</h3>
13 + <div class="page-list">
14 + @if(count($pages) > 0)
15 + @foreach($pages as $page)
16 + <div class="book-child">
17 + <h3>
18 + <a href="{{$page->getUrl() . '#' . $searchTerm}}" class="page">
19 + <i class="zmdi zmdi-file-text"></i>{{$page->name}}
20 + </a>
21 + </h3>
22 + <p class="text-muted">
23 + {!! $page->searchSnippet !!}
24 + </p>
25 + <hr>
26 + </div>
27 + @endforeach
28 + @else
29 + <p class="text-muted">No pages matched this search</p>
30 + @endif
31 + </div>
32 + </div>
33 +
34 + <div class="col-md-5 col-md-offset-1">
35 +
36 + @if(count($books) > 0)
37 + <h3>Matching Books</h3>
38 + <div class="page-list">
39 + @foreach($books as $book)
40 + <div class="book-child">
41 + <h3>
42 + <a href="{{$book->getUrl()}}" class="text-book">
43 + <i class="zmdi zmdi-book"></i>{{$book->name}}
44 + </a>
45 + </h3>
46 + <p class="text-muted">
47 + {!! $book->searchSnippet !!}
48 + </p>
49 + <hr>
50 + </div>
51 + @endforeach
52 + </div>
53 + @endif
54 +
55 + @if(count($chapters) > 0)
56 + <h3>Matching Chapters</h3>
57 + <div class="page-list">
58 + @foreach($chapters as $chapter)
59 + <div class="book-child">
60 + <h3>
61 + <a href="{{$chapter->getUrl()}}" class="text-chapter">
62 + <i class="zmdi zmdi-collection-bookmark"></i>{{$chapter->name}}
63 + </a>
64 + </h3>
65 + <p class="text-muted">
66 + {!! $chapter->searchSnippet !!}
67 + </p>
68 + <hr>
69 + </div>
70 + @endforeach
71 + </div>
72 + @endif
73 +
74 + </div>
75 +
76 +
77 + </div>
78 +
79 +
80 + </div>
81 +
82 +
83 +
84 +
85 +@stop
...\ No newline at end of file ...\ No newline at end of file