mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-11-23 06:35:43 +08:00
Updated Search experience including adding fulltext mysql indicies.
This commit is contained in:
parent
1b29d44689
commit
9a82d27548
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
52
app/Http/Controllers/SearchController.php
Normal file
52
app/Http/Controllers/SearchController.php
Normal 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]);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
37
database/migrations/2015_08_31_175240_add_search_indexes.php
Normal file
37
database/migrations/2015_08_31_175240_add_search_indexes.php
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
85
resources/views/search/all.blade.php
Normal file
85
resources/views/search/all.blade.php
Normal file
|
@ -0,0 +1,85 @@
|
|||
@extends('base')
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="container">
|
||||
|
||||
<h1>Search Results <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
|
Loading…
Reference in New Issue
Block a user