mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-06-02 23:56:56 +08:00
Merge pull request #340 from BookStackApp/search_system
Implementation of new search system
This commit is contained in:
@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
53
app/Console/Commands/RegenerateSearch.php
Normal file
53
app/Console/Commands/RegenerateSearch.php
Normal file
@ -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');
|
||||
}
|
||||
}
|
@ -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
|
||||
];
|
||||
|
||||
/**
|
||||
|
@ -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('/".*?"/', $term) || is_numeric($term)) {
|
||||
$term = str_replace('"', '', $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]);
|
||||
}
|
||||
|
||||
// Load in relations
|
||||
if ($this->isA('page')) {
|
||||
$search = $search->with('book', 'chapter', 'createdBy', 'updatedBy');
|
||||
} else if ($this->isA('chapter')) {
|
||||
$search = $search->with('book');
|
||||
}
|
||||
|
||||
return $search->orderBy($orderBy, 'desc');
|
||||
return $this->{$this->textField};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a generalised, common raw query that can be 'unioned' across entities.
|
||||
* @return string
|
||||
*/
|
||||
public function entityRawQuery(){return '';}
|
||||
|
||||
|
||||
}
|
||||
|
@ -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,84 +31,26 @@ 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]));
|
||||
|
||||
$page = $request->has('page') && is_int(intval($request->get('page'))) ? intval($request->get('page')) : 1;
|
||||
$nextPageLink = baseUrl('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1));
|
||||
|
||||
$results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20);
|
||||
$hasNextPage = $this->searchService->searchEntities($searchTerm, 'all', $page+1, 20)['count'] > 0;
|
||||
|
||||
return view('search/all', [
|
||||
'pages' => $pages,
|
||||
'books' => $books,
|
||||
'chapters' => $chapters,
|
||||
'searchTerm' => $searchTerm
|
||||
'entities' => $results['results'],
|
||||
'totalResults' => $results['total'],
|
||||
'searchTerm' => $searchTerm,
|
||||
'hasNextPage' => $hasNextPage,
|
||||
'nextPageLink' => $nextPageLink
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
$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();
|
||||
|
||||
$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
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search only the books in the system.
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View
|
||||
*/
|
||||
public function searchBooks(Request $request)
|
||||
{
|
||||
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
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches all entities within a book.
|
||||
@ -115,16 +61,24 @@ class SearchController extends Controller
|
||||
*/
|
||||
public function searchBook(Request $request, $bookId)
|
||||
{
|
||||
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->searchBook($bookId, $term);
|
||||
return view('partials/entity-list', ['entities' => $results]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches all entities within a chapter.
|
||||
* @param Request $request
|
||||
* @param integer $chapterId
|
||||
* @return \Illuminate\View\View
|
||||
* @internal param string $searchTerm
|
||||
*/
|
||||
public function searchChapter(Request $request, $chapterId)
|
||||
{
|
||||
$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.
|
||||
@ -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);
|
||||
|
13
app/Page.php
13
app/Page.php
@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
|
18
app/SearchTerm.php
Normal file
18
app/SearchTerm.php
Normal file
@ -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');
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
|
472
app/Services/SearchService.php
Normal file
472
app/Services/SearchService.php
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user