$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
]);
}
]);
}
+ 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
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;
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();
}
$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
]);
}
$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
]);
}
$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
]);
}
$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
]);
}
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]);
+ }
+
}
+
+
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');
Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity');
});
+ Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
+
// Links
Route::get('/link/{id}', 'PageController@redirectFromLink');
use Activity;
use BookStack\Book;
use BookStack\Chapter;
+use BookStack\Entity;
use BookStack\Exceptions\NotFoundException;
use Carbon\Carbon;
use DOMDocument;
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
* 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)
{
->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');
}
}]);
- 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;
+ });
+ };
}
};
}]);
&.disabled, &[disabled] {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
}
+ &:focus {
+ outline: 0;
+ }
}
#html-editor {
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;
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
'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',
-<div class="book" data-entity-type="book" data-entity-id="{{$book->id}}">
+<div class="book entity-list-item" data-entity-type="book" data-entity-id="{{$book->id}}">
<h3 class="text-book"><a class="text-book" href="{{$book->getUrl()}}"><i class="zmdi zmdi-book"></i>{{$book->name}}</a></h3>
@if(isset($book->searchSnippet))
<p class="text-muted">{!! $book->searchSnippet !!}</p>
-<div class="chapter" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
+<div class="chapter entity-list-item" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
<h3>
<a href="{{ $chapter->getUrl() }}" class="text-chapter">
<i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->name }}
-<div class="page {{$page->draft ? 'draft' : ''}}" data-entity-type="page" data-entity-id="{{$page->id}}">
+<div class="page {{$page->draft ? 'draft' : ''}} entity-list-item" data-entity-type="page" data-entity-id="{{$page->id}}">
<h3>
<a href="{{ $page->getUrl() }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ $page->name }}</a>
</h3>
<div class="container">
<h1>Move Page <small class="subheader">{{$page->name}}</small></h1>
- <div class="bordered" ng-cloak entity-selector>
- <input type="text" placeholder="Search">
- <div class="text-center" ng-if="loading">@include('partials/loading-icon')</div>
- </div>
+ <form action="{{ $page->getUrl() }}/move" method="POST">
+ {!! csrf_field() !!}
+ <input type="hidden" name="_method" value="PUT">
+
+ <div class="form-group">
+ <div entity-selector class="entity-selector large" entity-types="book,chapter">
+ <input type="hidden" entity-selector-input name="entity_selection">
+ <input type="text" placeholder="Search" ng-model="search" ng-model-options="{debounce: 200}" ng-change="searchEntities()">
+ <div class="text-center loading" ng-show="loading">@include('partials/loading-icon')</div>
+ <div ng-show="!loading" ng-bind-html="entityResults"></div>
+ </div>
+ </div>
+
+ <a href="{{ $page->getUrl() }}" class="button muted">Cancel</a>
+ <button type="submit" class="button pos">Move Page</button>
+ </form>
</div>
@stop
</ul>
</span>
@if(userCan('page-update', $page))
- <a href="{{$page->getUrl()}}/revisions" class="text-primary text-button"><i class="zmdi zmdi-replay"></i>Revisions</a>
<a href="{{$page->getUrl()}}/edit" class="text-primary text-button" ><i class="zmdi zmdi-edit"></i>Edit</a>
- <a href="{{$page->getUrl()}}/move" class="text-primary text-button" ><i class="zmdi zmdi-folder"></i>Move</a>
@endif
- @if(userCan('restrictions-manage', $page))
- <a href="{{$page->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a>
- @endif
- @if(userCan('page-delete', $page))
- <a href="{{$page->getUrl()}}/delete" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
+ @if(userCan('page-update', $page) || userCan('restrictions-manage', $page) || userCan('page-delete', $page))
+ <div dropdown class="dropdown-container">
+ <a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
+ <ul>
+ @if(userCan('page-update', $page))
+ <li><a href="{{$page->getUrl()}}/move" class="text-primary" ><i class="zmdi zmdi-folder"></i>Move</a></li>
+ <li><a href="{{$page->getUrl()}}/revisions" class="text-primary"><i class="zmdi zmdi-replay"></i>Revisions</a></li>
+ @endif
+ @if(userCan('restrictions-manage', $page))
+ <li><a href="{{$page->getUrl()}}/permissions" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Permissions</a></li>
+ @endif
+ @if(userCan('page-delete', $page))
+ <li><a href="{{$page->getUrl()}}/delete" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete</a></li>
+ @endif
+ </ul>
+ </div>
@endif
+
</div>
</div>
</div>
@if(Setting::get('app-color'))
<style>
header, #back-to-top, .primary-background {
- background-color: {{ Setting::get('app-color') }};
+ background-color: {{ Setting::get('app-color') }} !important;
}
.faded-small, .primary-background-light {
background-color: {{ Setting::get('app-color-light') }};