Updated Search experience including adding fulltext mysql indicies.

This commit is contained in:
Dan Brown 2015-08-31 20:11:44 +01:00
parent 1b29d44689
commit 9a82d27548
15 changed files with 298 additions and 68 deletions

View 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;
}
}

View 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();
}
}

View 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

View File

@ -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]);
}
}

View 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');

View 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;
}
}

View 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;
}
}

View File

@ -61,12 +61,35 @@ 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');
}
return $query->get();
$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 $pages;
}
/**

View File

@ -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();
}

View File

@ -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');
});
}
}

View 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
*/

View 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;

View File

@ -55,10 +55,15 @@
<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>

View File

@ -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

View File

@ -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