diff --git a/app/Entity.php b/app/Entity.php index 67edec4e0..5d4449f2b 100644 --- a/app/Entity.php +++ b/app/Entity.php @@ -197,8 +197,8 @@ class Entity extends Ownable * @param $path * @return string */ - public function getUrl($path) + public function getUrl($path = '/') { - return '/'; + return $path; } } diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 9cc73ae15..f42cf63ec 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -592,12 +592,70 @@ class PageController extends Controller return redirect($page->getUrl()); } + /** + * Show the view to copy a page. + * @param string $bookSlug + * @param string $pageSlug + * @return mixed + * @throws NotFoundException + */ + public function showCopy($bookSlug, $pageSlug) + { + $page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug); + $this->checkOwnablePermission('page-update', $page); + session()->flashInput(['name' => $page->name]); + return view('pages/copy', [ + 'book' => $page->book, + 'page' => $page + ]); + } + + /** + * Create a copy of a page within the requested target destination. + * @param string $bookSlug + * @param string $pageSlug + * @param Request $request + * @return mixed + * @throws NotFoundException + */ + public function copy($bookSlug, $pageSlug, Request $request) + { + $page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug); + $this->checkOwnablePermission('page-update', $page); + + $entitySelection = $request->get('entity_selection', null); + if ($entitySelection === null || $entitySelection === '') { + $parent = $page->chapter ? $page->chapter : $page->book; + } else { + $stringExploded = explode(':', $entitySelection); + $entityType = $stringExploded[0]; + $entityId = intval($stringExploded[1]); + + try { + $parent = $this->entityRepo->getById($entityType, $entityId); + } catch (\Exception $e) { + session()->flash(trans('entities.selected_book_chapter_not_found')); + return redirect()->back(); + } + } + + $this->checkOwnablePermission('page-create', $parent); + + $pageCopy = $this->entityRepo->copyPage($page, $parent, $request->get('name', '')); + + Activity::add($pageCopy, 'page_create', $pageCopy->book->id); + session()->flash('success', trans('entities.pages_copy_success')); + + return redirect($pageCopy->getUrl()); + } + /** * Set the permissions for this page. * @param string $bookSlug * @param string $pageSlug * @param Request $request * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + * @throws NotFoundException */ public function restrict($bookSlug, $pageSlug, Request $request) { diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index ddc92f705..49f9885ad 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -89,16 +89,17 @@ class SearchController extends Controller { $entityTypes = $request->filled('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']); $searchTerm = $request->get('term', false); + $permission = $request->get('permission', 'view'); // Search for entities otherwise show most popular if ($searchTerm !== false) { $searchTerm .= ' {type:'. implode('|', $entityTypes->toArray()) .'}'; - $entities = $this->searchService->searchEntities($searchTerm)['results']; + $entities = $this->searchService->searchEntities($searchTerm, 'all', 1, 20, $permission)['results']; } else { $entityNames = $entityTypes->map(function ($type) { return 'BookStack\\' . ucfirst($type); })->toArray(); - $entities = $this->viewService->getPopular(20, 0, $entityNames); + $entities = $this->viewService->getPopular(20, 0, $entityNames, $permission); } return view('search/entity-ajax-list', ['entities' => $entities]); diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php index 14f9d8d0e..bdd1e37b1 100644 --- a/app/Repos/EntityRepo.php +++ b/app/Repos/EntityRepo.php @@ -593,6 +593,30 @@ class EntityRepo return $slug; } + /** + * Get a new draft page instance. + * @param Book $book + * @param Chapter|bool $chapter + * @return Page + */ + public function getDraftPage(Book $book, $chapter = false) + { + $page = $this->page->newInstance(); + $page->name = trans('entities.pages_initial_name'); + $page->created_by = user()->id; + $page->updated_by = user()->id; + $page->draft = true; + + if ($chapter) { + $page->chapter_id = $chapter->id; + } + + $book->pages()->save($page); + $page = $this->page->find($page->id); + $this->permissionService->buildJointPermissionsForEntity($page); + return $page; + } + /** * Publish a draft page to make it a normal page. * Sets the slug and updates the content. @@ -621,6 +645,43 @@ class EntityRepo return $draftPage; } + /** + * Create a copy of a page in a new location with a new name. + * @param Page $page + * @param Entity $newParent + * @param string $newName + * @return Page + */ + public function copyPage(Page $page, Entity $newParent, $newName = '') + { + $newBook = $newParent->isA('book') ? $newParent : $newParent->book; + $newChapter = $newParent->isA('chapter') ? $newParent : null; + $copyPage = $this->getDraftPage($newBook, $newChapter); + $pageData = $page->getAttributes(); + + // Update name + if (!empty($newName)) { + $pageData['name'] = $newName; + } + + // Copy tags from previous page if set + if ($page->tags) { + $pageData['tags'] = []; + foreach ($page->tags as $tag) { + $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value]; + } + } + + // Set priority + if ($newParent->isA('chapter')) { + $pageData['priority'] = $this->getNewChapterPriority($newParent); + } else { + $pageData['priority'] = $this->getNewBookPriority($newParent); + } + + return $this->publishPageDraft($copyPage, $pageData); + } + /** * Saves a page revision into the system. * @param Page $page @@ -805,30 +866,6 @@ class EntityRepo return strip_tags($html); } - /** - * Get a new draft page instance. - * @param Book $book - * @param Chapter|bool $chapter - * @return Page - */ - public function getDraftPage(Book $book, $chapter = false) - { - $page = $this->page->newInstance(); - $page->name = trans('entities.pages_initial_name'); - $page->created_by = user()->id; - $page->updated_by = user()->id; - $page->draft = true; - - if ($chapter) { - $page->chapter_id = $chapter->id; - } - - $book->pages()->save($page); - $page = $this->page->find($page->id); - $this->permissionService->buildJointPermissionsForEntity($page); - return $page; - } - /** * Search for image usage within page content. * @param $imageString diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php index 331ed06c8..e74801dc8 100644 --- a/app/Services/PermissionService.php +++ b/app/Services/PermissionService.php @@ -630,16 +630,17 @@ class PermissionService * @param string $tableName * @param string $entityIdColumn * @param string $entityTypeColumn + * @param string $action * @return mixed */ - public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn) + public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn, $action = 'view') { if ($this->isAdmin()) { $this->clean(); return $query; } - $this->currentAction = 'view'; + $this->currentAction = $action; $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn]; $q = $query->where(function ($query) use ($tableDetails) { diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php index 056e1f077..6390b8bc4 100644 --- a/app/Services/SearchService.php +++ b/app/Services/SearchService.php @@ -67,7 +67,7 @@ class SearchService * @param int $count - Count of each entity to search, Total returned could can be larger and not guaranteed. * @return array[int, Collection]; */ - public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20) + public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view') { $terms = $this->parseSearchString($searchString); $entityTypes = array_keys($this->entities); @@ -87,8 +87,8 @@ class SearchService if (!in_array($entityType, $entityTypes)) { continue; } - $search = $this->searchEntityTable($terms, $entityType, $page, $count); - $entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, true); + $search = $this->searchEntityTable($terms, $entityType, $page, $count, $action); + $entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true); if ($entityTotal > $page * $count) { $hasMore = true; } @@ -147,12 +147,13 @@ class SearchService * @param string $entityType * @param int $page * @param int $count + * @param string $action * @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) + public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false) { - $query = $this->buildEntitySearchQuery($terms, $entityType); + $query = $this->buildEntitySearchQuery($terms, $entityType, $action); if ($getCount) { return $query->count(); } @@ -165,9 +166,10 @@ class SearchService * Create a search query for an entity * @param array $terms * @param string $entityType + * @param string $action * @return \Illuminate\Database\Eloquent\Builder */ - protected function buildEntitySearchQuery($terms, $entityType = 'page') + protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view') { $entity = $this->getEntity($entityType); $entitySelect = $entity->newQuery(); @@ -212,7 +214,7 @@ class SearchService } } - return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, 'view'); + return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action); } diff --git a/app/Services/ViewService.php b/app/Services/ViewService.php index ddcf2eb7e..cd869018c 100644 --- a/app/Services/ViewService.php +++ b/app/Services/ViewService.php @@ -51,11 +51,13 @@ class ViewService * @param int $count * @param int $page * @param bool|false|array $filterModel + * @param string $action - used for permission checking + * @return */ - public function getPopular($count = 10, $page = 0, $filterModel = false) + public function getPopular($count = 10, $page = 0, $filterModel = false, $action = 'view') { $skipCount = $count * $page; - $query = $this->permissionService->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type') + $query = $this->permissionService->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type', $action) ->select('*', 'viewable_id', 'viewable_type', \DB::raw('SUM(views) as view_count')) ->groupBy('viewable_id', 'viewable_type') ->orderBy('view_count', 'desc'); diff --git a/package-lock.json b/package-lock.json index c794a62da..30f58c000 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6825,11 +6825,6 @@ } } }, - "moment": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.21.0.tgz", - "integrity": "sha512-TCZ36BjURTeFTM/CwRcViQlfkMvL1/vFISuNLO5GkcVm1+QHfbSiNqZuWeMFjj1/3+uAjXswgRk30j1kkLYJBQ==" - }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", diff --git a/resources/assets/js/components/entity-selector.js b/resources/assets/js/components/entity-selector.js index 53358378a..5bd0d5497 100644 --- a/resources/assets/js/components/entity-selector.js +++ b/resources/assets/js/components/entity-selector.js @@ -7,7 +7,8 @@ class EntitySelector { this.lastClick = 0; let entityTypes = elem.hasAttribute('entity-types') ? elem.getAttribute('entity-types') : 'page,book,chapter'; - this.searchUrl = window.baseUrl(`/ajax/search/entities?types=${encodeURIComponent(entityTypes)}`); + let entityPermission = elem.hasAttribute('entity-permission') ? elem.getAttribute('entity-permission') : 'view'; + this.searchUrl = window.baseUrl(`/ajax/search/entities?types=${encodeURIComponent(entityTypes)}&permission=${encodeURIComponent(entityPermission)}`); this.input = elem.querySelector('[entity-selector-input]'); this.searchInput = elem.querySelector('[entity-selector-search]'); @@ -68,7 +69,6 @@ class EntitySelector { onClick(event) { let t = event.target; - console.log('click', t); if (t.matches('.entity-list-item *')) { event.preventDefault(); diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index 26f096327..c2744d906 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -31,6 +31,7 @@ return [ 'edit' => 'Edit', 'sort' => 'Sort', 'move' => 'Move', + 'copy' => 'Copy', 'reply' => 'Reply', 'delete' => 'Delete', 'search' => 'Search', diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index a4d3ae6e8..430655a87 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -166,6 +166,9 @@ return [ 'pages_not_in_chapter' => 'Page is not in a chapter', 'pages_move' => 'Move Page', 'pages_move_success' => 'Page moved to ":parentName"', + 'pages_copy' => 'Copy Page', + 'pages_copy_desination' => 'Copy Destination', + 'pages_copy_success' => 'Page successfully copied', 'pages_permissions' => 'Page Permissions', 'pages_permissions_success' => 'Page permissions updated', 'pages_revision' => 'Revision', diff --git a/resources/views/books/form.blade.php b/resources/views/books/form.blade.php index a47c26e26..bf94b5b07 100644 --- a/resources/views/books/form.blade.php +++ b/resources/views/books/form.blade.php @@ -30,9 +30,9 @@ -
+
- +
@include('components.tag-manager', ['entity' => isset($book)?$book:null, 'entityType' => 'chapter']) diff --git a/resources/views/components/entity-selector.blade.php b/resources/views/components/entity-selector.blade.php index 03e2066ed..89c574c28 100644 --- a/resources/views/components/entity-selector.blade.php +++ b/resources/views/components/entity-selector.blade.php @@ -1,5 +1,5 @@
-
+
@include('partials.loading-icon')
diff --git a/resources/views/pages/copy.blade.php b/resources/views/pages/copy.blade.php new file mode 100644 index 000000000..eb6afcad2 --- /dev/null +++ b/resources/views/pages/copy.blade.php @@ -0,0 +1,43 @@ +@extends('simple-layout') + +@section('toolbar') +
+ @include('pages._breadcrumbs', ['page' => $page]) +
+@stop + +@section('body') + +
+

 

+
+

@icon('copy') {{ trans('entities.pages_copy') }}

+
+
+ {!! csrf_field() !!} + +
+ + @include('form/text', ['name' => 'name']) +
+ +
+
+ +
+
+ @include('components.entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter', 'entityPermission' => 'page-create']) +
+
+ + +
+ {{ trans('common.cancel') }} + +
+
+
+
+
+ +@stop diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php index dabc7b965..288de3d84 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -22,6 +22,7 @@ @icon('more') {{ trans('common.more') }}
    @if(userCan('page-update', $page)) +
  • @icon('copy'){{ trans('common.copy') }}
  • @icon('folder'){{ trans('common.move') }}
  • @icon('history'){{ trans('entities.revisions') }}
  • @endif diff --git a/routes/web.php b/routes/web.php index be05cda90..f7b2347a5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -47,6 +47,8 @@ Route::group(['middleware' => 'auth'], function () { 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}/copy', 'PageController@showCopy'); + Route::post('/{bookSlug}/page/{pageSlug}/copy', 'PageController@copy'); Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete'); Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft'); Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showRestrict'); diff --git a/tests/Entity/SortTest.php b/tests/Entity/SortTest.php index 3b0831029..6e2b7c34e 100644 --- a/tests/Entity/SortTest.php +++ b/tests/Entity/SortTest.php @@ -1,6 +1,7 @@ book = \BookStack\Book::first(); + $this->book = Book::first(); } public function test_drafts_do_not_show_up() @@ -29,9 +30,9 @@ class SortTest extends TestCase public function test_page_move() { - $page = \BookStack\Page::first(); + $page = Page::first(); $currentBook = $page->book; - $newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first(); + $newBook = Book::where('id', '!=', $currentBook->id)->first(); $resp = $this->asAdmin()->get($page->getUrl() . '/move'); $resp->assertSee('Move Page'); @@ -39,7 +40,7 @@ class SortTest extends TestCase $movePageResp = $this->put($page->getUrl() . '/move', [ 'entity_selection' => 'book:' . $newBook->id ]); - $page = \BookStack\Page::find($page->id); + $page = Page::find($page->id); $movePageResp->assertRedirect($page->getUrl()); $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); @@ -51,10 +52,10 @@ class SortTest extends TestCase public function test_chapter_move() { - $chapter = \BookStack\Chapter::first(); + $chapter = Chapter::first(); $currentBook = $chapter->book; $pageToCheck = $chapter->pages->first(); - $newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first(); + $newBook = Book::where('id', '!=', $currentBook->id)->first(); $chapterMoveResp = $this->asAdmin()->get($chapter->getUrl() . '/move'); $chapterMoveResp->assertSee('Move Chapter'); @@ -63,7 +64,7 @@ class SortTest extends TestCase 'entity_selection' => 'book:' . $newBook->id ]); - $chapter = \BookStack\Chapter::find($chapter->id); + $chapter = Chapter::find($chapter->id); $moveChapterResp->assertRedirect($chapter->getUrl()); $this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book'); @@ -71,7 +72,7 @@ class SortTest extends TestCase $newBookResp->assertSee('moved chapter'); $newBookResp->assertSee($chapter->name); - $pageToCheck = \BookStack\Page::find($pageToCheck->id); + $pageToCheck = Page::find($pageToCheck->id); $this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book'); $pageCheckResp = $this->get($pageToCheck->getUrl()); $pageCheckResp->assertSee($newBook->name); @@ -120,4 +121,43 @@ class SortTest extends TestCase $checkResp->assertSee($newBook->name); } + public function test_page_copy() + { + $page = Page::first(); + $currentBook = $page->book; + $newBook = Book::where('id', '!=', $currentBook->id)->first(); + + $resp = $this->asEditor()->get($page->getUrl('/copy')); + $resp->assertSee('Copy Page'); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page' + ]); + + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $movePageResp->assertRedirect($pageCopy->getUrl()); + $this->assertTrue($pageCopy->book->id == $newBook->id, 'Page was copied to correct book'); + } + + public function test_page_copy_with_no_destination() + { + $page = Page::first(); + $currentBook = $page->book; + + $resp = $this->asEditor()->get($page->getUrl('/copy')); + $resp->assertSee('Copy Page'); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'name' => 'My copied test page' + ]); + + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $movePageResp->assertRedirect($pageCopy->getUrl()); + $this->assertTrue($pageCopy->book->id == $currentBook->id, 'Page was copied to correct book'); + $this->assertTrue($pageCopy->id !== $page->id, 'Page copy is not the same instance'); + } + } \ No newline at end of file