diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 52de19b5b..373e49de7 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -221,8 +221,8 @@ class PageController extends Controller $updateTime = $draft->updated_at->timestamp; $utcUpdateTimestamp = $updateTime + Carbon::createFromTimestamp(0)->offset; return response()->json([ - 'status' => 'success', - 'message' => 'Draft saved at ', + 'status' => 'success', + 'message' => 'Draft saved at ', 'timestamp' => $utcUpdateTimestamp ]); } @@ -468,6 +468,41 @@ class PageController extends Controller ]); } + public function move($bookSlug, $pageSlug, Request $request) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $page = $this->pageRepo->getBySlug($pageSlug, $book->id); + $this->checkOwnablePermission('page-update', $page); + + $entitySelection = $request->get('entity_selection', null); + if ($entitySelection === null || $entitySelection === '') { + return redirect($page->getUrl()); + } + + $stringExploded = explode(':', $entitySelection); + $entityType = $stringExploded[0]; + $entityId = intval($stringExploded[1]); + + $parent = false; + + if ($entityType == 'chapter') { + $parent = $this->chapterRepo->getById($entityId); + } else if ($entityType == 'book') { + $parent = $this->bookRepo->getById($entityId); + } + + if ($parent === false || $parent === null) { + session()->flash('The selected Book or Chapter was not found'); + return redirect()->back(); + } + + $this->pageRepo->changePageParent($page, $parent); + Activity::add($page, 'page_move', $page->book->id); + session()->flash('success', sprintf('Page moved to "%s"', $parent->name)); + + return redirect($page->getUrl()); + } + /** * Set the permissions for this page. * @param $bookSlug diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index e198dc767..7c7d7b254 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -2,10 +2,10 @@ namespace BookStack\Http\Controllers; +use BookStack\Services\ViewService; use Illuminate\Http\Request; use BookStack\Http\Requests; -use BookStack\Http\Controllers\Controller; use BookStack\Repos\BookRepo; use BookStack\Repos\ChapterRepo; use BookStack\Repos\PageRepo; @@ -15,18 +15,21 @@ class SearchController extends Controller protected $pageRepo; protected $bookRepo; protected $chapterRepo; + protected $viewService; /** * SearchController constructor. - * @param $pageRepo - * @param $bookRepo - * @param $chapterRepo + * @param PageRepo $pageRepo + * @param BookRepo $bookRepo + * @param ChapterRepo $chapterRepo + * @param ViewService $viewService */ - public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo) + public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo, ViewService $viewService) { $this->pageRepo = $pageRepo; $this->bookRepo = $bookRepo; $this->chapterRepo = $chapterRepo; + $this->viewService = $viewService; parent::__construct(); } @@ -48,9 +51,9 @@ class SearchController extends Controller $chapters = $this->chapterRepo->getBySearch($searchTerm, [], 10, $paginationAppends); $this->setPageTitle('Search For ' . $searchTerm); return view('search/all', [ - 'pages' => $pages, - 'books' => $books, - 'chapters' => $chapters, + 'pages' => $pages, + 'books' => $books, + 'chapters' => $chapters, 'searchTerm' => $searchTerm ]); } @@ -69,8 +72,8 @@ class SearchController extends Controller $pages = $this->pageRepo->getBySearch($searchTerm, [], 20, $paginationAppends); $this->setPageTitle('Page Search For ' . $searchTerm); return view('search/entity-search-list', [ - 'entities' => $pages, - 'title' => 'Page Search Results', + 'entities' => $pages, + 'title' => 'Page Search Results', 'searchTerm' => $searchTerm ]); } @@ -89,8 +92,8 @@ class SearchController extends Controller $chapters = $this->chapterRepo->getBySearch($searchTerm, [], 20, $paginationAppends); $this->setPageTitle('Chapter Search For ' . $searchTerm); return view('search/entity-search-list', [ - 'entities' => $chapters, - 'title' => 'Chapter Search Results', + 'entities' => $chapters, + 'title' => 'Chapter Search Results', 'searchTerm' => $searchTerm ]); } @@ -109,8 +112,8 @@ class SearchController extends Controller $books = $this->bookRepo->getBySearch($searchTerm, 20, $paginationAppends); $this->setPageTitle('Book Search For ' . $searchTerm); return view('search/entity-search-list', [ - 'entities' => $books, - 'title' => 'Book Search Results', + 'entities' => $books, + 'title' => 'Book Search Results', 'searchTerm' => $searchTerm ]); } @@ -134,4 +137,35 @@ class SearchController extends Controller return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]); } + + /** + * Search for a list of entities and return a partial HTML response of matching entities. + * Returns the most popular entities if no search is provided. + * @param Request $request + * @return mixed + */ + 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) { + if ($entityTypes->contains('page')) $entities = $entities->merge($this->pageRepo->getBySearch($searchTerm)->items()); + if ($entityTypes->contains('chapter')) $entities = $entities->merge($this->chapterRepo->getBySearch($searchTerm)->items()); + if ($entityTypes->contains('book')) $entities = $entities->merge($this->bookRepo->getBySearch($searchTerm)->items()); + $entities = $entities->sortByDesc('title_relevance'); + } else { + $entityNames = $entityTypes->map(function ($type) { + return 'BookStack\\' . ucfirst($type); + })->toArray(); + $entities = $this->viewService->getPopular(20, 0, $entityNames); + } + + return view('partials/entity-list', ['entities' => $entities]); + } + } + + diff --git a/app/Http/routes.php b/app/Http/routes.php index 90bcd593f..d7c090953 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -35,6 +35,7 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText'); Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit'); Route::get('/{bookSlug}/page/{pageSlug}/move', 'PageController@showMove'); + Route::put('/{bookSlug}/page/{pageSlug}/move', 'PageController@move'); Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete'); Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft'); Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showRestrict'); @@ -94,6 +95,8 @@ Route::group(['middleware' => 'auth'], function () { Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity'); }); + Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax'); + // Links Route::get('/link/{id}', 'PageController@redirectFromLink'); diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index 504c3fa3b..de050e1c7 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -3,6 +3,7 @@ use Activity; use BookStack\Book; use BookStack\Chapter; +use BookStack\Entity; use BookStack\Exceptions\NotFoundException; use Carbon\Carbon; use DOMDocument; @@ -572,6 +573,22 @@ class PageRepo extends EntityRepo return $page; } + + /** + * Change the page's parent to the given entity. + * @param Page $page + * @param Entity $parent + */ + public function changePageParent(Page $page, Entity $parent) + { + $book = $parent->isA('book') ? $parent : $parent->book; + $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0; + $page->save(); + $page = $this->changeBook($book->id, $page); + $page->load('book'); + $this->permissionService->buildJointPermissionsForEntity($book); + } + /** * Gets a suitable slug for the resource * @param $name diff --git a/app/Services/ViewService.php b/app/Services/ViewService.php index 849a164cf..aac9831f7 100644 --- a/app/Services/ViewService.php +++ b/app/Services/ViewService.php @@ -50,7 +50,7 @@ class ViewService * Get the entities with the most views. * @param int $count * @param int $page - * @param bool|false $filterModel + * @param bool|false|array $filterModel */ public function getPopular($count = 10, $page = 0, $filterModel = false) { @@ -60,7 +60,11 @@ class ViewService ->groupBy('viewable_id', 'viewable_type') ->orderBy('view_count', 'desc'); - if ($filterModel) $query->where('viewable_type', '=', get_class($filterModel)); + if ($filterModel && is_array($filterModel)) { + $query->whereIn('viewable_type', $filterModel); + } else if ($filterModel) { + $query->where('viewable_type', '=', get_class($filterModel)); + }; return $query->with('viewable')->skip($skipCount)->take($count)->get()->pluck('viewable'); } diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 54df2d2bf..f1fefd241 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -584,12 +584,62 @@ module.exports = function (ngApp, events) { }]); - ngApp.directive('entitySelector', ['$http', function ($http) { + ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) { return { restrict: 'A', + scope: true, link: function (scope, element, attrs) { scope.loading = true; - + scope.entityResults = false; + scope.search = ''; + + // Add input for forms + const input = element.find('[entity-selector-input]').first(); + + // Listen to entity item clicks + element.on('click', '.entity-list a', function(event) { + event.preventDefault(); + event.stopPropagation(); + let item = $(this).closest('[data-entity-type]'); + itemSelect(item); + }); + element.on('click', '[data-entity-type]', function(event) { + itemSelect($(this)); + }); + + // Select entity action + function itemSelect(item) { + let entityType = item.attr('data-entity-type'); + let entityId = item.attr('data-entity-id'); + let isSelected = !item.hasClass('selected'); + element.find('.selected').removeClass('selected').removeClass('primary-background'); + if (isSelected) item.addClass('selected').addClass('primary-background'); + let newVal = isSelected ? `${entityType}:${entityId}` : ''; + input.val(newVal); + } + + // Get search url with correct types + function getSearchUrl() { + let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter'); + return `/ajax/search/entities?types=${types}`; + } + + // Get initial contents + $http.get(getSearchUrl()).then(resp => { + scope.entityResults = $sce.trustAsHtml(resp.data); + scope.loading = false; + }); + + // Search when typing + scope.searchEntities = function() { + scope.loading = true; + input.val(''); + let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search); + $http.get(url).then(resp => { + scope.entityResults = $sce.trustAsHtml(resp.data); + scope.loading = false; + }); + }; } }; }]); diff --git a/resources/assets/sass/_forms.scss b/resources/assets/sass/_forms.scss index da015ec7c..4e643dcda 100644 --- a/resources/assets/sass/_forms.scss +++ b/resources/assets/sass/_forms.scss @@ -20,6 +20,9 @@ &.disabled, &[disabled] { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==); } + &:focus { + outline: 0; + } } #html-editor { diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index 0a7da179b..770d5eeb4 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -207,3 +207,59 @@ $btt-size: 40px; color: #EEE; } } + +.entity-selector { + border: 1px solid #DDD; + border-radius: 3px; + overflow: hidden; + font-size: 0.8em; + input[type="text"] { + width: 100%; + display: block; + border-radius: 0; + border: 0; + border-bottom: 1px solid #DDD; + font-size: 16px; + padding: $-s $-m; + } + .entity-list { + overflow-y: scroll; + height: 400px; + background-color: #EEEEEE; + } + .loading { + height: 400px; + padding-top: $-l; + } + .entity-list > p { + text-align: center; + padding-top: $-l; + font-size: 1.333em; + } + .entity-list > div { + padding-left: $-m; + padding-right: $-m; + background-color: #FFF; + transition: all ease-in-out 120ms; + cursor: pointer; + } +} + +.entity-list-item.selected { + h3, i, p ,a { + color: #EEE; + } +} + + + + + + + + + + + + + diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php index 8356d8302..c59513aa9 100644 --- a/resources/lang/en/activities.php +++ b/resources/lang/en/activities.php @@ -16,6 +16,7 @@ return [ 'page_delete_notification' => 'Page Successfully Deleted', 'page_restore' => 'restored page', 'page_restore_notification' => 'Page Successfully Restored', + 'page_move' => 'moved page', // Chapters 'chapter_create' => 'created chapter', diff --git a/resources/views/books/list-item.blade.php b/resources/views/books/list-item.blade.php index d3e0ef56b..945eb9015 100644 --- a/resources/views/books/list-item.blade.php +++ b/resources/views/books/list-item.blade.php @@ -1,4 +1,4 @@ -
{!! $book->searchSnippet !!}
diff --git a/resources/views/chapters/list-item.blade.php b/resources/views/chapters/list-item.blade.php index 71225e987..f66c3781f 100644 --- a/resources/views/chapters/list-item.blade.php +++ b/resources/views/chapters/list-item.blade.php @@ -1,4 +1,4 @@ -