diff --git a/app/Book.php b/app/Book.php index 51ea226b4..4e944ce10 100644 --- a/app/Book.php +++ b/app/Book.php @@ -48,14 +48,6 @@ class Book extends Entity { return $this->belongsTo(Image::class, 'image_id'); } - /* - * Get the edit url for this book. - * @return string - */ - public function getEditUrl() - { - return $this->getUrl() . '/edit'; - } /** * Get all pages within this book. @@ -75,6 +67,15 @@ class Book extends Entity return $this->hasMany(Chapter::class); } + /** + * Get the shelves this book is contained within. + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function shelves() + { + return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id'); + } + /** * Get an excerpt of this book's description to the specified length or less. * @param int $length diff --git a/app/Bookshelf.php b/app/Bookshelf.php new file mode 100644 index 000000000..ce2acbf0c --- /dev/null +++ b/app/Bookshelf.php @@ -0,0 +1,84 @@ +<?php namespace BookStack; + + +class Bookshelf extends Entity +{ + protected $table = 'bookshelves'; + + public $searchFactor = 3; + + protected $fillable = ['name', 'description', 'image_id']; + + /** + * Get the books in this shelf. + * Should not be used directly since does not take into account permissions. + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function books() + { + return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id')->orderBy('order', 'asc'); + } + + /** + * Get the url for this bookshelf. + * @param string|bool $path + * @return string + */ + public function getUrl($path = false) + { + if ($path !== false) { + return baseUrl('/shelves/' . urlencode($this->slug) . '/' . trim($path, '/')); + } + return baseUrl('/shelves/' . urlencode($this->slug)); + } + + /** + * Returns BookShelf cover image, if cover does not exists return default cover image. + * @param int $width - Width of the image + * @param int $height - Height of the image + * @return string + */ + public function getBookCover($width = 440, $height = 250) + { + $default = baseUrl('/book_default_cover.png'); + if (!$this->image_id) { + return $default; + } + + try { + $cover = $this->cover ? baseUrl($this->cover->getThumb($width, $height, false)) : $default; + } catch (\Exception $err) { + $cover = $default; + } + return $cover; + } + + /** + * Get the cover image of the book + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function cover() + { + return $this->belongsTo(Image::class, 'image_id'); + } + + /** + * Get an excerpt of this book's description to the specified length or less. + * @param int $length + * @return string + */ + public function getExcerpt($length = 100) + { + $description = $this->description; + 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\\\\BookShelf' 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"; + } +} diff --git a/app/Entity.php b/app/Entity.php index 5d4449f2b..fb1c6d48b 100644 --- a/app/Entity.php +++ b/app/Entity.php @@ -152,7 +152,7 @@ class Entity extends Ownable */ public static function getEntityInstance($type) { - $types = ['Page', 'Book', 'Chapter']; + $types = ['Page', 'Book', 'Chapter', 'Bookshelf']; $className = str_replace([' ', '-', '_'], '', ucwords($type)); if (!in_array($className, $types)) { return null; diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php new file mode 100644 index 000000000..d1752d180 --- /dev/null +++ b/app/Http/Controllers/BookshelfController.php @@ -0,0 +1,244 @@ +<?php namespace BookStack\Http\Controllers; + +use Activity; +use BookStack\Book; +use BookStack\Bookshelf; +use BookStack\Repos\EntityRepo; +use BookStack\Repos\UserRepo; +use BookStack\Services\ExportService; +use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Views; + +class BookshelfController extends Controller +{ + + protected $entityRepo; + protected $userRepo; + protected $exportService; + + /** + * BookController constructor. + * @param EntityRepo $entityRepo + * @param UserRepo $userRepo + * @param ExportService $exportService + */ + public function __construct(EntityRepo $entityRepo, UserRepo $userRepo, ExportService $exportService) + { + $this->entityRepo = $entityRepo; + $this->userRepo = $userRepo; + $this->exportService = $exportService; + parent::__construct(); + } + + /** + * Display a listing of the book. + * @return Response + */ + public function index() + { + $shelves = $this->entityRepo->getAllPaginated('bookshelf', 18); + $recents = $this->signedIn ? $this->entityRepo->getRecentlyViewed('bookshelf', 4, 0) : false; + $popular = $this->entityRepo->getPopular('bookshelf', 4, 0); + $new = $this->entityRepo->getRecentlyCreated('bookshelf', 4, 0); + $shelvesViewType = setting()->getUser($this->currentUser, 'bookshelves_view_type', config('app.views.bookshelves', 'grid')); + + $this->setPageTitle(trans('entities.shelves')); + return view('shelves/index', [ + 'shelves' => $shelves, + 'recents' => $recents, + 'popular' => $popular, + 'new' => $new, + 'shelvesViewType' => $shelvesViewType + ]); + } + + /** + * Show the form for creating a new bookshelf. + * @return Response + */ + public function create() + { + $this->checkPermission('bookshelf-create-all'); + $books = $this->entityRepo->getAll('book', false, 'update'); + $this->setPageTitle(trans('entities.shelves_create')); + return view('shelves/create', ['books' => $books]); + } + + /** + * Store a newly created bookshelf in storage. + * @param Request $request + * @return Response + */ + public function store(Request $request) + { + $this->checkPermission('bookshelf-create-all'); + $this->validate($request, [ + 'name' => 'required|string|max:255', + 'description' => 'string|max:1000', + ]); + + $bookshelf = $this->entityRepo->createFromInput('bookshelf', $request->all()); + $this->entityRepo->updateShelfBooks($bookshelf, $request->get('books', '')); + Activity::add($bookshelf, 'bookshelf_create'); + + return redirect($bookshelf->getUrl()); + } + + + /** + * Display the specified bookshelf. + * @param String $slug + * @return Response + * @throws \BookStack\Exceptions\NotFoundException + */ + public function show(string $slug) + { + $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */ + $this->checkOwnablePermission('book-view', $bookshelf); + + $books = $this->entityRepo->getBookshelfChildren($bookshelf); + Views::add($bookshelf); + + $this->setPageTitle($bookshelf->getShortName()); + return view('shelves/show', [ + 'shelf' => $bookshelf, + 'books' => $books, + 'activity' => Activity::entityActivity($bookshelf, 20, 0) + ]); + } + + /** + * Show the form for editing the specified bookshelf. + * @param $slug + * @return Response + * @throws \BookStack\Exceptions\NotFoundException + */ + public function edit(string $slug) + { + $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */ + $this->checkOwnablePermission('bookshelf-update', $bookshelf); + + $shelfBooks = $this->entityRepo->getBookshelfChildren($bookshelf); + $shelfBookIds = $shelfBooks->pluck('id'); + $books = $this->entityRepo->getAll('book', false, 'update'); + $books = $books->filter(function ($book) use ($shelfBookIds) { + return !$shelfBookIds->contains($book->id); + }); + + $this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $bookshelf->getShortName()])); + return view('shelves/edit', [ + 'shelf' => $bookshelf, + 'books' => $books, + 'shelfBooks' => $shelfBooks, + ]); + } + + + /** + * Update the specified bookshelf in storage. + * @param Request $request + * @param string $slug + * @return Response + * @throws \BookStack\Exceptions\NotFoundException + */ + public function update(Request $request, string $slug) + { + $shelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */ + $this->checkOwnablePermission('bookshelf-update', $shelf); + $this->validate($request, [ + 'name' => 'required|string|max:255', + 'description' => 'string|max:1000', + ]); + + $shelf = $this->entityRepo->updateFromInput('bookshelf', $shelf, $request->all()); + $this->entityRepo->updateShelfBooks($shelf, $request->get('books', '')); + Activity::add($shelf, 'bookshelf_update'); + + return redirect($shelf->getUrl()); + } + + + /** + * Shows the page to confirm deletion + * @param $slug + * @return \Illuminate\View\View + * @throws \BookStack\Exceptions\NotFoundException + */ + public function showDelete(string $slug) + { + $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */ + $this->checkOwnablePermission('bookshelf-delete', $bookshelf); + + $this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $bookshelf->getShortName()])); + return view('shelves/delete', ['shelf' => $bookshelf]); + } + + /** + * Remove the specified bookshelf from storage. + * @param string $slug + * @return Response + * @throws \BookStack\Exceptions\NotFoundException + * @throws \Throwable + */ + public function destroy(string $slug) + { + $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */ + $this->checkOwnablePermission('bookshelf-delete', $bookshelf); + Activity::addMessage('bookshelf_delete', 0, $bookshelf->name); + $this->entityRepo->destroyBookshelf($bookshelf); + return redirect('/shelves'); + } + + /** + * Show the Restrictions view. + * @param $slug + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + * @throws \BookStack\Exceptions\NotFoundException + */ + public function showRestrict(string $slug) + { + $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); + $this->checkOwnablePermission('restrictions-manage', $bookshelf); + + $roles = $this->userRepo->getRestrictableRoles(); + return view('shelves.restrictions', [ + 'shelf' => $bookshelf, + 'roles' => $roles + ]); + } + + /** + * Set the restrictions for this bookshelf. + * @param $slug + * @param Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + * @throws \BookStack\Exceptions\NotFoundException + */ + public function restrict(string $slug, Request $request) + { + $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); + $this->checkOwnablePermission('restrictions-manage', $bookshelf); + + $this->entityRepo->updateEntityPermissionsFromRequest($request, $bookshelf); + session()->flash('success', trans('entities.shelves_permissions_updated')); + return redirect($bookshelf->getUrl()); + } + + /** + * Copy the permissions of a bookshelf to the child books. + * @param string $slug + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + * @throws \BookStack\Exceptions\NotFoundException + */ + public function copyPermissions(string $slug) + { + $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); + $this->checkOwnablePermission('restrictions-manage', $bookshelf); + + $updateCount = $this->entityRepo->copyBookshelfPermissions($bookshelf); + session()->flash('success', trans('entities.shelves_copy_permission_success', ['count' => $updateCount])); + return redirect($bookshelf->getUrl()); + } + +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 2077f6888..e47250318 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -33,42 +33,42 @@ class HomeController extends Controller $recents = $this->signedIn ? Views::getUserRecentlyViewed(12*$recentFactor, 0) : $this->entityRepo->getRecentlyCreated('book', 12*$recentFactor); $recentlyUpdatedPages = $this->entityRepo->getRecentlyUpdated('page', 12); - - $customHomepage = false; - $books = false; - $booksViewType = false; - - // Check book homepage - $bookHomepageSetting = setting('app-book-homepage'); - if ($bookHomepageSetting) { - $books = $this->entityRepo->getAllPaginated('book', 18); - $booksViewType = setting()->getUser($this->currentUser, 'books_view_type', config('app.views.books', 'list')); - } else { - // Check custom homepage - $homepageSetting = setting('app-homepage'); - if ($homepageSetting) { - $id = intval(explode(':', $homepageSetting)[0]); - $customHomepage = $this->entityRepo->getById('page', $id, false, true); - $this->entityRepo->renderPage($customHomepage, true); - } + $homepageOptions = ['default', 'books', 'bookshelves', 'page']; + $homepageOption = setting('app-homepage-type', 'default'); + if (!in_array($homepageOption, $homepageOptions)) { + $homepageOption = 'default'; } - $view = 'home'; - if ($bookHomepageSetting) { - $view = 'home-book'; - } else if ($customHomepage) { - $view = 'home-custom'; - } - - return view('common/' . $view, [ + $commonData = [ 'activity' => $activity, 'recents' => $recents, 'recentlyUpdatedPages' => $recentlyUpdatedPages, 'draftPages' => $draftPages, - 'customHomepage' => $customHomepage, - 'books' => $books, - 'booksViewType' => $booksViewType - ]); + ]; + + if ($homepageOption === 'bookshelves') { + $shelves = $this->entityRepo->getAllPaginated('bookshelf', 18); + $shelvesViewType = setting()->getUser($this->currentUser, 'bookshelves_view_type', config('app.views.bookshelves', 'grid')); + $data = array_merge($commonData, ['shelves' => $shelves, 'shelvesViewType' => $shelvesViewType]); + return view('common.home-shelves', $data); + } + + if ($homepageOption === 'books') { + $books = $this->entityRepo->getAllPaginated('book', 18); + $booksViewType = setting()->getUser($this->currentUser, 'books_view_type', config('app.views.books', 'list')); + $data = array_merge($commonData, ['books' => $books, 'booksViewType' => $booksViewType]); + return view('common.home-book', $data); + } + + if ($homepageOption === 'page') { + $homepageSetting = setting('app-homepage', '0:'); + $id = intval(explode(':', $homepageSetting)[0]); + $customHomepage = $this->entityRepo->getById('page', $id, false, true); + $this->entityRepo->renderPage($customHomepage, true); + return view('common.home-custom', array_merge($commonData, ['customHomepage' => $customHomepage])); + } + + return view('common.home', $commonData); } /** diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index d50baa86f..f6bd13e6f 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -252,7 +252,7 @@ class UserController extends Controller return $this->currentUser->id == $id; }); - $viewType = $request->get('book_view_type'); + $viewType = $request->get('view_type'); if (!in_array($viewType, ['grid', 'list'])) { $viewType = 'list'; } @@ -262,4 +262,27 @@ class UserController extends Controller return redirect()->back(302, [], "/settings/users/$id"); } + + /** + * Update the user's preferred shelf-list display setting. + * @param $id + * @param Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function switchShelfView($id, Request $request) + { + $this->checkPermissionOr('users-manage', function () use ($id) { + return $this->currentUser->id == $id; + }); + + $viewType = $request->get('view_type'); + if (!in_array($viewType, ['grid', 'list'])) { + $viewType = 'list'; + } + + $user = $this->user->findOrFail($id); + setting()->putUser($user, 'bookshelves_view_type', $viewType); + + return redirect()->back(302, [], "/settings/users/$id"); + } } diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php index bdd1e37b1..11f89fc34 100644 --- a/app/Repos/EntityRepo.php +++ b/app/Repos/EntityRepo.php @@ -1,6 +1,7 @@ <?php namespace BookStack\Repos; use BookStack\Book; +use BookStack\Bookshelf; use BookStack\Chapter; use BookStack\Entity; use BookStack\Exceptions\NotFoundException; @@ -18,6 +19,10 @@ use Illuminate\Support\Collection; class EntityRepo { + /** + * @var Bookshelf + */ + public $bookshelf; /** * @var Book $book @@ -67,6 +72,7 @@ class EntityRepo /** * EntityRepo constructor. + * @param Bookshelf $bookshelf * @param Book $book * @param Chapter $chapter * @param Page $page @@ -77,6 +83,7 @@ class EntityRepo * @param SearchService $searchService */ public function __construct( + Bookshelf $bookshelf, Book $book, Chapter $chapter, Page $page, @@ -86,11 +93,13 @@ class EntityRepo TagRepo $tagRepo, SearchService $searchService ) { + $this->bookshelf = $bookshelf; $this->book = $book; $this->chapter = $chapter; $this->page = $page; $this->pageRevision = $pageRevision; $this->entities = [ + 'bookshelf' => $this->bookshelf, 'page' => $this->page, 'chapter' => $this->chapter, 'book' => $this->book @@ -331,6 +340,17 @@ class EntityRepo ->skip($count * $page)->take($count)->get(); } + /** + * Get the child items for a chapter sorted by priority but + * with draft items floated to the top. + * @param Bookshelf $bookshelf + * @return \Illuminate\Database\Eloquent\Collection|static[] + */ + public function getBookshelfChildren(Bookshelf $bookshelf) + { + return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get(); + } + /** * Get all child objects of a book. * Returns a sorted collection of Pages and Chapters. @@ -533,6 +553,28 @@ class EntityRepo return $entityModel; } + /** + * Sync the books assigned to a shelf from a comma-separated list + * of book IDs. + * @param Bookshelf $shelf + * @param string $books + */ + public function updateShelfBooks(Bookshelf $shelf, string $books) + { + $ids = explode(',', $books); + + // Check books exist and match ordering + $bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id'); + $syncData = []; + foreach ($ids as $index => $id) { + if ($bookIds->contains($id)) { + $syncData[$id] = ['order' => $index]; + } + } + + $shelf->books()->sync($syncData); + } + /** * Change the book that an entity belongs to. * @param string $type @@ -1154,9 +1196,22 @@ class EntityRepo $this->permissionService->buildJointPermissionsForEntity($book); } + /** + * Destroy a bookshelf instance + * @param Bookshelf $shelf + * @throws \Throwable + */ + public function destroyBookshelf(Bookshelf $shelf) + { + $this->destroyEntityCommonRelations($shelf); + $shelf->delete(); + } + /** * Destroy the provided book and all its child entities. * @param Book $book + * @throws NotifyException + * @throws \Throwable */ public function destroyBook(Book $book) { @@ -1166,17 +1221,14 @@ class EntityRepo foreach ($book->chapters as $chapter) { $this->destroyChapter($chapter); } - \Activity::removeEntity($book); - $book->views()->delete(); - $book->permissions()->delete(); - $this->permissionService->deleteJointPermissionsForEntity($book); - $this->searchService->deleteEntityTerms($book); + $this->destroyEntityCommonRelations($book); $book->delete(); } /** * Destroy a chapter and its relations. * @param Chapter $chapter + * @throws \Throwable */ public function destroyChapter(Chapter $chapter) { @@ -1186,11 +1238,7 @@ class EntityRepo $page->save(); } } - \Activity::removeEntity($chapter); - $chapter->views()->delete(); - $chapter->permissions()->delete(); - $this->permissionService->deleteJointPermissionsForEntity($chapter); - $this->searchService->deleteEntityTerms($chapter); + $this->destroyEntityCommonRelations($chapter); $chapter->delete(); } @@ -1198,23 +1246,18 @@ class EntityRepo * Destroy a given page along with its dependencies. * @param Page $page * @throws NotifyException + * @throws \Throwable */ public function destroyPage(Page $page) { - \Activity::removeEntity($page); - $page->views()->delete(); - $page->tags()->delete(); - $page->revisions()->delete(); - $page->permissions()->delete(); - $this->permissionService->deleteJointPermissionsForEntity($page); - $this->searchService->deleteEntityTerms($page); - // Check if set as custom homepage $customHome = setting('app-homepage', '0:'); if (intval($page->id) === intval(explode(':', $customHome)[0])) { throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl()); } + $this->destroyEntityCommonRelations($page); + // Delete Attached Files $attachmentService = app(AttachmentService::class); foreach ($page->attachments as $attachment) { @@ -1223,4 +1266,46 @@ class EntityRepo $page->delete(); } + + /** + * Destroy or handle the common relations connected to an entity. + * @param Entity $entity + * @throws \Throwable + */ + protected function destroyEntityCommonRelations(Entity $entity) + { + \Activity::removeEntity($entity); + $entity->views()->delete(); + $entity->permissions()->delete(); + $entity->tags()->delete(); + $entity->comments()->delete(); + $this->permissionService->deleteJointPermissionsForEntity($entity); + $this->searchService->deleteEntityTerms($entity); + } + + /** + * Copy the permissions of a bookshelf to all child books. + * Returns the number of books that had permissions updated. + * @param Bookshelf $bookshelf + * @return int + * @throws \Throwable + */ + public function copyBookshelfPermissions(Bookshelf $bookshelf) + { + $shelfPermissions = $bookshelf->permissions()->get(['role_id', 'action'])->toArray(); + $shelfBooks = $bookshelf->books()->get(); + $updatedBookCount = 0; + + foreach ($shelfBooks as $book) { + if (!userCan('restrictions-manage', $book)) continue; + $book->permissions()->delete(); + $book->restricted = $bookshelf->restricted; + $book->permissions()->createMany($shelfPermissions); + $book->save(); + $this->permissionService->buildJointPermissionsForEntity($book); + $updatedBookCount++; + } + + return $updatedBookCount; + } } diff --git a/app/Repos/PermissionsRepo.php b/app/Repos/PermissionsRepo.php index 6f7ea1dc8..68c9270be 100644 --- a/app/Repos/PermissionsRepo.php +++ b/app/Repos/PermissionsRepo.php @@ -80,7 +80,7 @@ class PermissionsRepo /** * Updates an existing role. - * Ensure Admin role always has all permissions. + * Ensure Admin role always have core permissions. * @param $roleId * @param $roleData * @throws PermissionsException @@ -90,13 +90,18 @@ class PermissionsRepo $role = $this->role->findOrFail($roleId); $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : []; - $this->assignRolePermissions($role, $permissions); - if ($role->system_name === 'admin') { - $permissions = $this->permission->all()->pluck('id')->toArray(); - $role->permissions()->sync($permissions); + $permissions = array_merge($permissions, [ + 'users-manage', + 'user-roles-manage', + 'restrictions-manage-all', + 'restrictions-manage-own', + 'settings-manage', + ]); } + $this->assignRolePermissions($role, $permissions); + $role->fill($roleData); $role->save(); $this->permissionService->buildJointPermissionForRole($role); diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php index 0dd316b34..dade68290 100644 --- a/app/Services/PermissionService.php +++ b/app/Services/PermissionService.php @@ -1,6 +1,7 @@ <?php namespace BookStack\Services; use BookStack\Book; +use BookStack\Bookshelf; use BookStack\Chapter; use BookStack\Entity; use BookStack\EntityPermission; @@ -25,6 +26,7 @@ class PermissionService public $book; public $chapter; public $page; + public $bookshelf; protected $db; @@ -38,22 +40,26 @@ class PermissionService * PermissionService constructor. * @param JointPermission $jointPermission * @param EntityPermission $entityPermission + * @param Role $role * @param Connection $db + * @param Bookshelf $bookshelf * @param Book $book * @param Chapter $chapter * @param Page $page - * @param Role $role */ - public function __construct(JointPermission $jointPermission, EntityPermission $entityPermission, Connection $db, Book $book, Chapter $chapter, Page $page, Role $role) + public function __construct( + JointPermission $jointPermission, EntityPermission $entityPermission, Role $role, Connection $db, + Bookshelf $bookshelf, Book $book, Chapter $chapter, Page $page + ) { $this->db = $db; $this->jointPermission = $jointPermission; $this->entityPermission = $entityPermission; $this->role = $role; + $this->bookshelf = $bookshelf; $this->book = $book; $this->chapter = $chapter; $this->page = $page; - // TODO - Update so admin still goes through filters } /** @@ -159,6 +165,12 @@ class PermissionService $this->bookFetchQuery()->chunk(5, function ($books) use ($roles) { $this->buildJointPermissionsForBooks($books, $roles); }); + + // Chunk through all bookshelves + $this->bookshelf->newQuery()->select(['id', 'restricted', 'created_by']) + ->chunk(50, function ($shelves) use ($roles) { + $this->buildJointPermissionsForShelves($shelves, $roles); + }); } /** @@ -174,6 +186,20 @@ class PermissionService }]); } + /** + * @param Collection $shelves + * @param array $roles + * @param bool $deleteOld + * @throws \Throwable + */ + protected function buildJointPermissionsForShelves($shelves, $roles, $deleteOld = false) + { + if ($deleteOld) { + $this->deleteManyJointPermissionsForEntities($shelves->all()); + } + $this->createManyJointPermissions($shelves, $roles); + } + /** * Build joint permissions for an array of books * @param Collection $books @@ -204,6 +230,7 @@ class PermissionService /** * Rebuild the entity jointPermissions for a particular entity. * @param Entity $entity + * @throws \Throwable */ public function buildJointPermissionsForEntity(Entity $entity) { @@ -214,7 +241,9 @@ class PermissionService return; } - $entities[] = $entity->book; + if ($entity->book) { + $entities[] = $entity->book; + } if ($entity->isA('page') && $entity->chapter_id) { $entities[] = $entity->chapter; @@ -226,13 +255,13 @@ class PermissionService } } - $this->deleteManyJointPermissionsForEntities($entities); $this->buildJointPermissionsForEntities(collect($entities)); } /** * Rebuild the entity jointPermissions for a collection of entities. * @param Collection $entities + * @throws \Throwable */ public function buildJointPermissionsForEntities(Collection $entities) { @@ -254,6 +283,12 @@ class PermissionService $this->bookFetchQuery()->chunk(20, function ($books) use ($roles) { $this->buildJointPermissionsForBooks($books, $roles); }); + + // Chunk through all bookshelves + $this->bookshelf->newQuery()->select(['id', 'restricted', 'created_by']) + ->chunk(50, function ($shelves) use ($roles) { + $this->buildJointPermissionsForShelves($shelves, $roles); + }); } /** @@ -412,7 +447,7 @@ class PermissionService return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess); } - if ($entity->isA('book')) { + if ($entity->isA('book') || $entity->isA('bookshelf')) { return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn); } @@ -484,11 +519,6 @@ class PermissionService */ public function checkOwnableUserAccess(Ownable $ownable, $permission) { - if ($this->isAdmin()) { - $this->clean(); - return true; - } - $explodedPermission = explode('-', $permission); $baseQuery = $ownable->where('id', '=', $ownable->id); @@ -581,17 +611,16 @@ class PermissionService $query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U")) ->mergeBindings($pageSelect)->mergeBindings($chapterSelect); - if (!$this->isAdmin()) { - $whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)') - ->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type') - ->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles()) - ->where(function ($query) { - $query->where('jp.has_permission', '=', 1)->orWhere(function ($query) { - $query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id); - }); + // Add joint permission filter + $whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)') + ->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type') + ->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles()) + ->where(function ($query) { + $query->where('jp.has_permission', '=', 1)->orWhere(function ($query) { + $query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id); }); - $query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery); - } + }); + $query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery); $query->orderBy('draft', 'desc')->orderBy('priority', 'asc'); $this->clean(); @@ -619,11 +648,6 @@ class PermissionService }); } - if ($this->isAdmin()) { - $this->clean(); - return $query; - } - $this->currentAction = $action; return $this->entityRestrictionQuery($query); } @@ -639,10 +663,6 @@ class PermissionService */ public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn, $action = 'view') { - if ($this->isAdmin()) { - $this->clean(); - return $query; - } $this->currentAction = $action; $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn]; @@ -675,11 +695,6 @@ class PermissionService */ public function filterRelatedPages($query, $tableName, $entityIdColumn) { - if ($this->isAdmin()) { - $this->clean(); - return $query; - } - $this->currentAction = 'view'; $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn]; @@ -704,19 +719,6 @@ class PermissionService return $q; } - /** - * Check if the current user is an admin. - * @return bool - */ - private function isAdmin() - { - if ($this->isAdminUser === null) { - $this->isAdminUser = ($this->currentUser()->id !== null) ? $this->currentUser()->hasSystemRole('admin') : false; - } - - return $this->isAdminUser; - } - /** * Get the current user * @return User diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index c68f5c1e1..3d6ed1d63 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -21,6 +21,14 @@ $factory->define(BookStack\User::class, function ($faker) { ]; }); +$factory->define(BookStack\Bookshelf::class, function ($faker) { + return [ + 'name' => $faker->sentence, + 'slug' => str_random(10), + 'description' => $faker->paragraph + ]; +}); + $factory->define(BookStack\Book::class, function ($faker) { return [ 'name' => $faker->sentence, diff --git a/database/migrations/2016_04_20_192649_create_joint_permissions_table.php b/database/migrations/2016_04_20_192649_create_joint_permissions_table.php index 4c1b43c4e..ce11f7b88 100644 --- a/database/migrations/2016_04_20_192649_create_joint_permissions_table.php +++ b/database/migrations/2016_04_20_192649_create_joint_permissions_table.php @@ -74,10 +74,6 @@ class CreateJointPermissionsTable extends Migration // Update admin role with system name DB::table('roles')->where('name', '=', 'admin')->update(['system_name' => 'admin']); - - // Generate the new entity jointPermissions - $restrictionService = app(\BookStack\Services\PermissionService::class); - $restrictionService->buildJointPermissions(); } /** diff --git a/database/migrations/2018_08_04_115700_create_bookshelves_table.php b/database/migrations/2018_08_04_115700_create_bookshelves_table.php new file mode 100644 index 000000000..e92b0edef --- /dev/null +++ b/database/migrations/2018_08_04_115700_create_bookshelves_table.php @@ -0,0 +1,101 @@ +<?php + +use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Migrations\Migration; + +class CreateBookshelvesTable extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create('bookshelves', function (Blueprint $table) { + $table->increments('id'); + $table->string('name', 200); + $table->string('slug', 200); + $table->text('description'); + $table->integer('created_by')->nullable()->default(null); + $table->integer('updated_by')->nullable()->default(null); + $table->boolean('restricted')->default(false); + $table->integer('image_id')->nullable()->default(null); + $table->timestamps(); + + $table->index('slug'); + $table->index('created_by'); + $table->index('updated_by'); + $table->index('restricted'); + }); + + Schema::create('bookshelves_books', function (Blueprint $table) { + $table->integer('bookshelf_id')->unsigned(); + $table->integer('book_id')->unsigned(); + $table->integer('order')->unsigned(); + + $table->foreign('bookshelf_id')->references('id')->on('bookshelves') + ->onUpdate('cascade')->onDelete('cascade'); + $table->foreign('book_id')->references('id')->on('books') + ->onUpdate('cascade')->onDelete('cascade'); + + $table->primary(['bookshelf_id', 'book_id']); + }); + + // Copy existing role permissions from Books + $ops = ['View All', 'View Own', 'Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own']; + foreach ($ops as $op) { + $dbOpName = strtolower(str_replace(' ', '-', $op)); + $roleIdsWithBookPermission = DB::table('role_permissions') + ->leftJoin('permission_role', 'role_permissions.id', '=', 'permission_role.permission_id') + ->leftJoin('roles', 'roles.id', '=', 'permission_role.role_id') + ->where('role_permissions.name', '=', 'book-' . $dbOpName)->get(['roles.id'])->pluck('id'); + + $permId = DB::table('role_permissions')->insertGetId([ + 'name' => 'bookshelf-' . $dbOpName, + 'display_name' => $op . ' ' . 'BookShelves', + 'created_at' => \Carbon\Carbon::now()->toDateTimeString(), + 'updated_at' => \Carbon\Carbon::now()->toDateTimeString() + ]); + + $rowsToInsert = $roleIdsWithBookPermission->map(function($roleId) use ($permId) { + return [ + 'role_id' => $roleId, + 'permission_id' => $permId + ]; + })->toArray(); + + // Assign view permission to all current roles + DB::table('permission_role')->insert($rowsToInsert); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // Drop created permissions + $ops = ['bookshelf-create-all','bookshelf-create-own','bookshelf-delete-all','bookshelf-delete-own','bookshelf-update-all','bookshelf-update-own','bookshelf-view-all','bookshelf-view-own']; + + $permissionIds = DB::table('role_permissions')->whereIn('name', $ops) + ->get(['id'])->pluck('id')->toArray(); + DB::table('permission_role')->whereIn('permission_id', $permissionIds)->delete(); + DB::table('role_permissions')->whereIn('id', $permissionIds)->delete(); + + // Drop shelves table + Schema::dropIfExists('bookshelves_books'); + Schema::dropIfExists('bookshelves'); + + // Drop related polymorphic items + DB::table('activities')->where('entity_type', '=', 'BookStack\Bookshelf')->delete(); + DB::table('views')->where('viewable_type', '=', 'BookStack\Bookshelf')->delete(); + DB::table('entity_permissions')->where('restrictable_type', '=', 'BookStack\Bookshelf')->delete(); + DB::table('tags')->where('entity_type', '=', 'BookStack\Bookshelf')->delete(); + DB::table('search_terms')->where('entity_type', '=', 'BookStack\Bookshelf')->delete(); + DB::table('comments')->where('entity_type', '=', 'BookStack\Bookshelf')->delete(); + } +} diff --git a/database/seeds/DummyContentSeeder.php b/database/seeds/DummyContentSeeder.php index 41ac6650d..dcf589352 100644 --- a/database/seeds/DummyContentSeeder.php +++ b/database/seeds/DummyContentSeeder.php @@ -21,23 +21,29 @@ class DummyContentSeeder extends Seeder $role = \BookStack\Role::getRole('viewer'); $viewerUser->attachRole($role); - factory(\BookStack\Book::class, 5)->create(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]) - ->each(function($book) use ($editorUser) { - $chapters = factory(\BookStack\Chapter::class, 3)->create(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]) - ->each(function($chapter) use ($editorUser, $book){ - $pages = factory(\BookStack\Page::class, 3)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'book_id' => $book->id]); + $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]; + + factory(\BookStack\Book::class, 5)->create($byData) + ->each(function($book) use ($editorUser, $byData) { + $chapters = factory(\BookStack\Chapter::class, 3)->create($byData) + ->each(function($chapter) use ($editorUser, $book, $byData){ + $pages = factory(\BookStack\Page::class, 3)->make(array_merge($byData, ['book_id' => $book->id])); $chapter->pages()->saveMany($pages); }); - $pages = factory(\BookStack\Page::class, 3)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]); + $pages = factory(\BookStack\Page::class, 3)->make($byData); $book->chapters()->saveMany($chapters); $book->pages()->saveMany($pages); }); - $largeBook = factory(\BookStack\Book::class)->create(['name' => 'Large book' . str_random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]); - $pages = factory(\BookStack\Page::class, 200)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]); - $chapters = factory(\BookStack\Chapter::class, 50)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]); + $largeBook = factory(\BookStack\Book::class)->create(array_merge($byData, ['name' => 'Large book' . str_random(10)])); + $pages = factory(\BookStack\Page::class, 200)->make($byData); + $chapters = factory(\BookStack\Chapter::class, 50)->make($byData); $largeBook->pages()->saveMany($pages); $largeBook->chapters()->saveMany($chapters); + + $shelves = factory(\BookStack\Bookshelf::class, 10)->create($byData); + $largeBook->shelves()->attach($shelves->pluck('id')); + app(\BookStack\Services\PermissionService::class)->buildJointPermissions(); app(\BookStack\Services\SearchService::class)->indexAllEntities(); } diff --git a/package-lock.json b/package-lock.json index ec4da5ce2..f8c43993b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5857,6 +5857,21 @@ "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz", "integrity": "sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg==" }, + "jquery-sortable": { + "version": "0.9.13", + "resolved": "https://registry.npmjs.org/jquery-sortable/-/jquery-sortable-0.9.13.tgz", + "integrity": "sha1-HL+2VQE6B0c3BXHwbiL1JKAP+6I=", + "requires": { + "jquery": "^2.1.2" + }, + "dependencies": { + "jquery": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-2.2.4.tgz", + "integrity": "sha1-LInWiJterFIqfuoywUUhVZxsvwI=" + } + } + }, "js-base64": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.4.3.tgz", diff --git a/package.json b/package.json index 12d972cf9..58f2dad5e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "codemirror": "^5.26.0", "dropzone": "^5.4.0", "jquery": "^3.3.1", + "jquery-sortable": "^0.9.13", "markdown-it": "^8.3.1", "markdown-it-task-lists": "^2.0.0", "vue": "^2.2.6", diff --git a/resources/assets/icons/bookshelf.svg b/resources/assets/icons/bookshelf.svg new file mode 100644 index 000000000..03da68f96 --- /dev/null +++ b/resources/assets/icons/bookshelf.svg @@ -0,0 +1,2 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M1.088 2.566h17.42v17.42H1.088z" fill="none"/><path d="M4 20.058h15.892V22H4z"/><path d="M2.902 1.477h17.42v17.42H2.903z" fill="none"/><g><path d="M6.658 3.643V18h-2.38V3.643zM11.326 3.643V18H8.947V3.643zM14.722 3.856l5.613 13.214-2.19.93-5.613-13.214z"/></g></svg> + diff --git a/resources/assets/js/components/homepage-control.js b/resources/assets/js/components/homepage-control.js new file mode 100644 index 000000000..e1f66a592 --- /dev/null +++ b/resources/assets/js/components/homepage-control.js @@ -0,0 +1,22 @@ + +class HomepageControl { + + constructor(elem) { + this.elem = elem; + this.typeControl = elem.querySelector('[name="setting-app-homepage-type"]'); + this.pagePickerContainer = elem.querySelector('[page-picker-container]'); + + this.typeControl.addEventListener('change', this.controlPagePickerVisibility.bind(this)); + this.controlPagePickerVisibility(); + } + + controlPagePickerVisibility() { + const showPagePicker = this.typeControl.value === 'page'; + this.pagePickerContainer.style.display = (showPagePicker ? 'block' : 'none'); + } + + + +} + +module.exports = HomepageControl; \ No newline at end of file diff --git a/resources/assets/js/components/index.js b/resources/assets/js/components/index.js index aa69f3265..768e0983f 100644 --- a/resources/assets/js/components/index.js +++ b/resources/assets/js/components/index.js @@ -18,6 +18,8 @@ let componentMapping = { 'collapsible': require('./collapsible'), 'toggle-switch': require('./toggle-switch'), 'page-display': require('./page-display'), + 'shelf-sort': require('./shelf-sort'), + 'homepage-control': require('./homepage-control'), }; window.components = {}; diff --git a/resources/assets/js/components/page-picker.js b/resources/assets/js/components/page-picker.js index e697d5f68..5fd2920f4 100644 --- a/resources/assets/js/components/page-picker.js +++ b/resources/assets/js/components/page-picker.js @@ -15,18 +15,20 @@ class PagePicker { } setupListeners() { - // Select click - this.selectButton.addEventListener('click', event => { - window.EntitySelectorPopup.show(entity => { - this.setValue(entity.id, entity.name); - }); - }); + this.selectButton.addEventListener('click', this.showPopup.bind(this)); + this.display.parentElement.addEventListener('click', this.showPopup.bind(this)); this.resetButton.addEventListener('click', event => { this.setValue('', ''); }); } + showPopup() { + window.EntitySelectorPopup.show(entity => { + this.setValue(entity.id, entity.name); + }); + } + setValue(value, name) { this.value = value; this.input.value = value; diff --git a/resources/assets/js/components/shelf-sort.js b/resources/assets/js/components/shelf-sort.js new file mode 100644 index 000000000..59ac712a4 --- /dev/null +++ b/resources/assets/js/components/shelf-sort.js @@ -0,0 +1,71 @@ + +class ShelfSort { + + constructor(elem) { + this.elem = elem; + this.sortGroup = this.initSortable(); + this.input = document.getElementById('books-input'); + this.setupListeners(); + } + + initSortable() { + const sortable = require('jquery-sortable'); + const placeHolderContent = this.getPlaceholderHTML(); + + return $('.scroll-box').sortable({ + group: 'shelf-books', + exclude: '.instruction,.scroll-box-placeholder', + containerSelector: 'div.scroll-box', + itemSelector: '.scroll-box-item', + placeholder: placeHolderContent, + onDrop: this.onDrop.bind(this) + }); + } + + setupListeners() { + this.elem.addEventListener('click', event => { + const sortItem = event.target.closest('.scroll-box-item:not(.instruction)'); + if (sortItem) { + event.preventDefault(); + this.sortItemClick(sortItem); + } + }); + } + + /** + * Called when a sort item is clicked. + * @param {Element} sortItem + */ + sortItemClick(sortItem) { + const lists = this.elem.querySelectorAll('.scroll-box'); + const newList = Array.from(lists).filter(list => sortItem.parentElement !== list); + if (newList.length > 0) { + newList[0].appendChild(sortItem); + } + this.onChange(); + } + + onDrop($item, container, _super) { + this.onChange(); + _super($item, container); + } + + onChange() { + const data = this.sortGroup.sortable('serialize').get(); + this.input.value = data[0].map(item => item.id).join(','); + const instruction = this.elem.querySelector('.scroll-box-item.instruction'); + instruction.parentNode.insertBefore(instruction, instruction.parentNode.children[0]); + } + + getPlaceholderHTML() { + const placeHolder = document.querySelector('.scroll-box-placeholder'); + placeHolder.style.display = 'block'; + const placeHolderContent = placeHolder.outerHTML; + placeHolder.style.display = 'none'; + return placeHolderContent; + } + + +} + +module.exports = ShelfSort; \ No newline at end of file diff --git a/resources/assets/sass/_grid.scss b/resources/assets/sass/_grid.scss index 8f15153b5..0e1f85ce6 100644 --- a/resources/assets/sass/_grid.scss +++ b/resources/assets/sass/_grid.scss @@ -192,8 +192,26 @@ div[class^="col-"] img { flex-direction: column; border: 1px solid #ddd; min-width: 100px; + h2 { + width: 100%; + font-size: 1.5em; + margin: 0 0 10px; + } + h2 a { + display: block; + width: 100%; + line-height: 1.2; + text-decoration: none; + } + p { + font-size: .85em; + margin: 0; + line-height: 1.6em; + } .grid-card-content { flex: 1; + border-top: 0; + border-bottom-width: 2px; } .grid-card-content, .grid-card-footer { padding: $-l; @@ -203,6 +221,23 @@ div[class^="col-"] img { } } +.book-grid-item .grid-card-content h2 a { + color: $color-book; + fill: $color-book; +} + +.bookshelf-grid-item .grid-card-content h2 a { + color: $color-bookshelf; + fill: $color-bookshelf; +} + +.book-grid-item .grid-card-footer { + p.small { + font-size: .8em; + margin: 0; + } +} + @include smaller-than($m) { .grid.third { grid-template-columns: 1fr 1fr; diff --git a/resources/assets/sass/_lists.scss b/resources/assets/sass/_lists.scss index e8d131b52..0bf6be4c3 100644 --- a/resources/assets/sass/_lists.scss +++ b/resources/assets/sass/_lists.scss @@ -412,32 +412,3 @@ ul.pagination { } } -.book-grid-item .grid-card-content { - border-top: 0; - border-bottom-width: 2px; - h2 { - width: 100%; - font-size: 1.5em; - margin: 0 0 10px; - } - h2 a { - display: block; - width: 100%; - line-height: 1.2; - color: #009688;; - fill: #009688;; - text-decoration: none; - } - p { - font-size: .85em; - margin: 0; - line-height: 1.6em; - } -} - -.book-grid-item .grid-card-footer { - p.small { - font-size: .8em; - margin: 0; - } -} \ No newline at end of file diff --git a/resources/assets/sass/_text.scss b/resources/assets/sass/_text.scss index da11846d8..63a91c968 100644 --- a/resources/assets/sass/_text.scss +++ b/resources/assets/sass/_text.scss @@ -281,6 +281,14 @@ p.secondary, p .secondary, span.secondary, .text-secondary { } } +.text-bookshelf { + color: $color-bookshelf; + fill: $color-bookshelf; + &:hover { + color: $color-bookshelf; + fill: $color-bookshelf; + } +} .text-book { color: $color-book; fill: $color-book; diff --git a/resources/assets/sass/_variables.scss b/resources/assets/sass/_variables.scss index e62d37efe..006d1b3f0 100644 --- a/resources/assets/sass/_variables.scss +++ b/resources/assets/sass/_variables.scss @@ -47,6 +47,7 @@ $warning: $secondary; $primary-faded: rgba(21, 101, 192, 0.15); // Item Colors +$color-bookshelf: #af5a5a; $color-book: #009688; $color-chapter: #ef7c3c; $color-page: $primary; diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index 0b2dfbf75..ab5972cbd 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -206,6 +206,12 @@ $btt-size: 40px; transition: all ease-in-out 120ms; cursor: pointer; } + &.compact { + font-size: 10px; + .entity-item-snippet { + display: none; + } + } } .entity-list-item.selected { @@ -214,6 +220,20 @@ $btt-size: 40px; } } +.scroll-box { + max-height: 250px; + overflow-y: scroll; + border: 1px solid #DDD; + border-radius: 3px; + .scroll-box-item { + padding: $-xs $-m; + border-bottom: 1px solid #DDD; + &:last-child { + border-bottom: 0; + } + } +} + .center-box { margin: $-xxl auto 0 auto; width: 420px; diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php index 187fe1e53..153ae33f0 100644 --- a/resources/lang/en/activities.php +++ b/resources/lang/en/activities.php @@ -37,6 +37,14 @@ return [ 'book_sort' => 'sorted book', 'book_sort_notification' => 'Book Successfully Re-sorted', + // Bookshelves + 'bookshelf_create' => 'created Bookshelf', + 'bookshelf_create_notification' => 'Bookshelf Successfully Created', + 'bookshelf_update' => 'updated bookshelf', + 'bookshelf_update_notification' => 'Bookshelf Successfully Updated', + 'bookshelf_delete' => 'deleted bookshelf', + 'bookshelf_delete_notification' => 'Bookshelf Successfully Deleted', + // Other 'commented_on' => 'commented on', ]; diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index c2744d906..8e86129e2 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -52,6 +52,7 @@ return [ 'details' => 'Details', 'grid_view' => 'Grid View', 'list_view' => 'List View', + 'default' => 'Default', /** * Header diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index c99887401..4f110b724 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -64,6 +64,37 @@ return [ 'search_set_date' => 'Set Date', 'search_update' => 'Update Search', + /** + * Shelves + */ + 'shelves' => 'Shelves', + 'shelves_long' => 'Bookshelves', + 'shelves_empty' => 'No shelves have been created', + 'shelves_create' => 'Create New Shelf', + 'shelves_popular' => 'Popular Shelves', + 'shelves_new' => 'New Shelves', + 'shelves_popular_empty' => 'The most popular shelves will appear here.', + 'shelves_new_empty' => 'The most recently created shelves will appear here.', + 'shelves_save' => 'Save Shelf', + 'shelves_books' => 'Books on this shelf', + 'shelves_add_books' => 'Add books to this shelf', + 'shelves_drag_books' => 'Drag books here to add them to this shelf', + 'shelves_empty_contents' => 'This shelf has no books assigned to it', + 'shelves_edit_and_assign' => 'Edit shelf to assign books', + 'shelves_edit_named' => 'Edit Bookshelf :name', + 'shelves_edit' => 'Edit Bookshelf', + 'shelves_delete' => 'Delete Bookshelf', + 'shelves_delete_named' => 'Delete Bookshelf :name', + 'shelves_delete_explain' => "This will delete the bookshelf with the name ':name'. Contained books will not be deleted.", + 'shelves_delete_confirmation' => 'Are you sure you want to delete this bookshelf?', + 'shelves_permissions' => 'Bookshelf Permissions', + 'shelves_permissions_updated' => 'Bookshelf Permissions Updated', + 'shelves_permissions_active' => 'Bookshelf Permissions Active', + 'shelves_copy_permissions_to_books' => 'Copy Permissions to Books', + 'shelves_copy_permissions' => 'Copy Permissions', + 'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.', + 'shelves_copy_permission_success' => 'Bookshelf permissions copied to :count books', + /** * Books */ @@ -199,6 +230,7 @@ return [ 'message' => ':start :time. Take care not to overwrite each other\'s updates!', ], 'pages_draft_discarded' => 'Draft discarded, The editor has been updated with the current page content', + 'pages_specific' => 'Specific Page', /** * Editor sidebar @@ -206,6 +238,7 @@ return [ 'page_tags' => 'Page Tags', 'chapter_tags' => 'Chapter Tags', 'book_tags' => 'Book Tags', + 'shelf_tags' => 'Shelf Tags', 'tag' => 'Tag', 'tags' => 'Tags', 'tag_value' => 'Tag Value (Optional)', diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index a86a1cdfc..fb09841cf 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -49,6 +49,7 @@ return [ // Entities 'entity_not_found' => 'Entity not found', + 'bookshelf_not_found' => 'Bookshelf not found', 'book_not_found' => 'Book not found', 'page_not_found' => 'Page not found', 'chapter_not_found' => 'Chapter not found', diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index d6fbb6107..80ab77d19 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -32,9 +32,8 @@ return [ 'app_primary_color' => 'Application primary color', 'app_primary_color_desc' => 'This should be a hex value. <br>Leave empty to reset to the default color.', 'app_homepage' => 'Application Homepage', - 'app_homepage_desc' => 'Select a page to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', - 'app_homepage_default' => 'Default homepage view chosen', - 'app_homepage_books' => 'Or select the books page as your homepage. This will override any page selected as your homepage.', + 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', + 'app_homepage_select' => 'Select a page', 'app_disable_comments' => 'Disable comments', 'app_disable_comments_desc' => 'Disable comments across all pages in the application. Existing comments are not shown.', @@ -91,6 +90,7 @@ return [ 'role_manage_settings' => 'Manage app settings', 'role_asset' => 'Asset Permissions', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', + 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php index 8f6c2eb46..016f8e833 100644 --- a/resources/views/base.blade.php +++ b/resources/views/base.blade.php @@ -33,7 +33,7 @@ <header id="header"> <div class="container fluid"> <div class="row"> - <div class="col-sm-4"> + <div class="col-sm-4 col-md-3"> <a href="{{ baseUrl('/') }}" class="logo"> @if(setting('app-logo', '') !== 'none') <img class="logo-image" src="{{ setting('app-logo', '') === '' ? baseUrl('/logo.png') : baseUrl(setting('app-logo', '')) }}" alt="Logo"> @@ -43,7 +43,7 @@ @endif </a> </div> - <div class="col-sm-8"> + <div class="col-sm-8 col-md-9"> <div class="float right"> <div class="header-search"> <form action="{{ baseUrl('/search') }}" method="GET" class="search-box"> @@ -52,6 +52,9 @@ </form> </div> <div class="links text-center"> + @if(userCan('bookshelf-view-all') || userCan('bookshelf-view-own')) + <a href="{{ baseUrl('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a> + @endif <a href="{{ baseUrl('/books') }}">@icon('book'){{ trans('entities.books') }}</a> @if(signedInUser() && userCan('settings-manage')) <a href="{{ baseUrl('/settings') }}">@icon('settings'){{ trans('settings.settings') }}</a> diff --git a/resources/views/books/list.blade.php b/resources/views/books/list.blade.php index 1c2056a79..9459cc008 100644 --- a/resources/views/books/list.blade.php +++ b/resources/views/books/list.blade.php @@ -1,9 +1,5 @@ -@if($booksViewType === 'list') - <div class="container small"> -@else - <div class="container"> -@endif +<div class="container{{ $booksViewType === 'list' ? ' small' : '' }}"> <h1>{{ trans('entities.books') }}</h1> @if(count($books) > 0) @if($booksViewType === 'list') @@ -25,7 +21,7 @@ @else <p class="text-muted">{{ trans('entities.books_empty') }}</p> @if(userCan('books-create-all')) - <a href="{{ baseUrl("/create-book") }}" class="text-pos">@icon('edit'){{ trans('entities.create_one_now') }}</a> + <a href="{{ baseUrl("/create-book") }}" class="text-pos">@icon('edit'){{ trans('entities.create_now') }}</a> @endif @endif </div> \ No newline at end of file diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index d0a2eb2f7..e5845b495 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -25,7 +25,7 @@ <a dropdown-toggle class="text-primary text-button">@icon('more'){{ trans('common.more') }}</a> <ul> @if(userCan('book-update', $book)) - <li><a href="{{$book->getEditUrl()}}" class="text-primary">@icon('edit'){{ trans('common.edit') }}</a></li> + <li><a href="{{ $book->getUrl('/edit') }}" class="text-primary">@icon('edit'){{ trans('common.edit') }}</a></li> <li><a href="{{ $book->getUrl('/sort') }}" class="text-primary">@icon('sort'){{ trans('common.sort') }}</a></li> @endif @if(userCan('restrictions-manage', $book)) diff --git a/resources/views/books/view-toggle.blade.php b/resources/views/books/view-toggle.blade.php index 61df7ab8d..63eb9b9d3 100644 --- a/resources/views/books/view-toggle.blade.php +++ b/resources/views/books/view-toggle.blade.php @@ -1,7 +1,7 @@ <form action="{{ baseUrl("/settings/users/{$currentUser->id}/switch-book-view") }}" method="POST" class="inline"> {!! csrf_field() !!} {!! method_field('PATCH') !!} - <input type="hidden" value="{{ $booksViewType === 'list'? 'grid' : 'list' }}" name="book_view_type"> + <input type="hidden" value="{{ $booksViewType === 'list'? 'grid' : 'list' }}" name="view_type"> @if ($booksViewType === 'list') <button type="submit" class="text-pos text-button">@icon('grid'){{ trans('common.grid_view') }}</button> @else diff --git a/resources/views/common/home-shelves.blade.php b/resources/views/common/home-shelves.blade.php new file mode 100644 index 000000000..3ae055b33 --- /dev/null +++ b/resources/views/common/home-shelves.blade.php @@ -0,0 +1,18 @@ +@extends('sidebar-layout') + +@section('toolbar') + <div class="col-sm-6 faded"> + <div class="action-buttons text-left"> + <a expand-toggle=".entity-list.compact .entity-item-snippet" class="text-primary text-button">@icon('expand-text'){{ trans('common.toggle_details') }}</a> + @include('shelves/view-toggle', ['shelvesViewType' => $shelvesViewType]) + </div> + </div> +@stop + +@section('sidebar') + @include('common/home-sidebar') +@stop + +@section('body') + @include('shelves/list', ['shelves' => $shelves, 'shelvesViewType' => $shelvesViewType]) +@stop \ No newline at end of file diff --git a/resources/views/common/home.blade.php b/resources/views/common/home.blade.php index bbddb072d..cc20fc68e 100644 --- a/resources/views/common/home.blade.php +++ b/resources/views/common/home.blade.php @@ -10,7 +10,7 @@ @section('body') - <div class="container"> + <div class="container" id="home-default"> <div class="row"> <div class="col-sm-4"> diff --git a/resources/views/partials/entity-list.blade.php b/resources/views/partials/entity-list.blade.php index c90b953ea..371f38d71 100644 --- a/resources/views/partials/entity-list.blade.php +++ b/resources/views/partials/entity-list.blade.php @@ -8,6 +8,8 @@ @include('books/list-item', ['book' => $entity]) @elseif($entity->isA('chapter')) @include('chapters/list-item', ['chapter' => $entity, 'hidePages' => true]) + @elseif($entity->isA('bookshelf')) + @include('shelves/list-item', ['bookshelf' => $entity]) @endif @if($index !== count($entities) - 1) diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index 64017e6e0..3c563a61c 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -76,12 +76,22 @@ <input type="text" value="{{ setting('app-color') }}" name="setting-app-color" id="setting-app-color" placeholder="#0288D1"> <input type="hidden" value="{{ setting('app-color-light') }}" name="setting-app-color-light" id="setting-app-color-light"> </div> - <div class="form-group" id="homepage-control"> + <div homepage-control class="form-group" id="homepage-control"> <label for="setting-app-homepage">{{ trans('settings.app_homepage') }}</label> <p class="small">{{ trans('settings.app_homepage_desc') }}</p> - @include('components.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_default'), 'value' => setting('app-homepage')]) - <p class="small">{{ trans('settings.app_homepage_books') }}</p> - @include('components.toggle-switch', ['name' => 'setting-app-book-homepage', 'value' => setting('app-book-homepage')]) + + <select name="setting-app-homepage-type" id="setting-app-homepage-type"> + <option @if(setting('app-homepage-type') === 'default') selected @endif value="default">{{ trans('common.default') }}</option> + <option @if(setting('app-homepage-type') === 'books') selected @endif value="books">{{ trans('entities.books') }}</option> + <option @if(setting('app-homepage-type') === 'bookshelves') selected @endif value="bookshelves">{{ trans('entities.shelves') }}</option> + <option @if(setting('app-homepage-type') === 'page') selected @endif value="page">{{ trans('entities.pages_specific') }}</option> + </select> + + <br><br> + + <div page-picker-container style="display: none;"> + @include('components.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_select'), 'value' => setting('app-homepage')]) + </div> </div> </div> diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php index 6a8e27487..619229a65 100644 --- a/resources/views/settings/roles/form.blade.php +++ b/resources/views/settings/roles/form.blade.php @@ -36,6 +36,10 @@ <h5>{{ trans('settings.role_asset') }}</h5> <p>{{ trans('settings.role_asset_desc') }}</p> + @if (isset($role) && $role->system_name === 'admin') + <p>{{ trans('settings.role_asset_admins') }}</p> + @endif + <table class="table"> <tr> <th width="20%"></th> @@ -44,6 +48,24 @@ <th width="20%">{{ trans('common.edit') }}</th> <th width="20%">{{ trans('common.delete') }}</th> </tr> + <tr> + <td>{{ trans('entities.shelves_long') }}</td> + <td> + <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-create-all']) {{ trans('settings.role_all') }}</label> + </td> + <td> + <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-view-own']) {{ trans('settings.role_own') }}</label> + <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-view-all']) {{ trans('settings.role_all') }}</label> + </td> + <td> + <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-update-own']) {{ trans('settings.role_own') }}</label> + <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-update-all']) {{ trans('settings.role_all') }}</label> + </td> + <td> + <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-delete-own']) {{ trans('settings.role_own') }}</label> + <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-delete-all']) {{ trans('settings.role_all') }}</label> + </td> + </tr> <tr> <td>{{ trans('entities.books') }}</td> <td> diff --git a/resources/views/shelves/_breadcrumbs.blade.php b/resources/views/shelves/_breadcrumbs.blade.php new file mode 100644 index 000000000..91b4252ef --- /dev/null +++ b/resources/views/shelves/_breadcrumbs.blade.php @@ -0,0 +1,3 @@ +<div class="breadcrumbs"> + <a href="{{$shelf->getUrl()}}" class="text-bookshelf text-button">@icon('bookshelf'){{ $shelf->getShortName() }}</a> +</div> \ No newline at end of file diff --git a/resources/views/shelves/create.blade.php b/resources/views/shelves/create.blade.php new file mode 100644 index 000000000..32e40a4ae --- /dev/null +++ b/resources/views/shelves/create.blade.php @@ -0,0 +1,31 @@ +@extends('simple-layout') + +@section('toolbar') + <div class="col-sm-8 faded"> + <div class="breadcrumbs"> + <a href="{{ baseUrl('/shelves') }}" class="text-button">@icon('bookshelf'){{ trans('entities.shelves') }}</a> + <span class="sep">»</span> + <a href="{{ baseUrl('/create-shelf') }}" class="text-button">@icon('add'){{ trans('entities.shelves_create') }}</a> + </div> + </div> +@stop + +@section('body') + + <div class="container small"> + <p> </p> + <div class="card"> + <h3>@icon('add') {{ trans('entities.shelves_create') }}</h3> + <div class="body"> + <form action="{{ baseUrl("/shelves") }}" method="POST" enctype="multipart/form-data"> + @include('shelves/form', ['shelf' => null, 'books' => $books]) + </form> + </div> + </div> + </div> + + <p class="margin-top large"><br></p> + + @include('components.image-manager', ['imageType' => 'cover']) + +@stop \ No newline at end of file diff --git a/resources/views/shelves/delete.blade.php b/resources/views/shelves/delete.blade.php new file mode 100644 index 000000000..f3ad62456 --- /dev/null +++ b/resources/views/shelves/delete.blade.php @@ -0,0 +1,30 @@ +@extends('simple-layout') + +@section('toolbar') + <div class="col-sm-12 faded"> + @include('shelves._breadcrumbs', ['shelf' => $shelf]) + </div> +@stop + +@section('body') + + <div class="container small"> + <p> </p> + <div class="card"> + <h3>@icon('delete') {{ trans('entities.shelves_delete') }}</h3> + <div class="body"> + <p>{{ trans('entities.shelves_delete_explain', ['name' => $shelf->name]) }}</p> + <p class="text-neg">{{ trans('entities.shelves_delete_confirmation') }}</p> + + <form action="{{ $shelf->getUrl() }}" method="POST"> + {!! csrf_field() !!} + <input type="hidden" name="_method" value="DELETE"> + + <a href="{{ $shelf->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a> + <button type="submit" class="button">{{ trans('common.confirm') }}</button> + </form> + </div> + </div> + </div> + +@stop \ No newline at end of file diff --git a/resources/views/shelves/edit.blade.php b/resources/views/shelves/edit.blade.php new file mode 100644 index 000000000..ab88051e5 --- /dev/null +++ b/resources/views/shelves/edit.blade.php @@ -0,0 +1,24 @@ +@extends('simple-layout') + +@section('toolbar') + <div class="col-sm-12 faded"> + @include('shelves._breadcrumbs', ['shelf' => $shelf]) + </div> +@stop + +@section('body') + + <div class="container small"> + <p> </p> + <div class="card"> + <h3>@icon('edit') {{ trans('entities.shelves_edit') }}</h3> + <div class="body"> + <form action="{{ $shelf->getUrl() }}" method="POST"> + <input type="hidden" name="_method" value="PUT"> + @include('shelves/form', ['model' => $shelf]) + </form> + </div> + </div> + </div> +@include('components.image-manager', ['imageType' => 'cover']) +@stop \ No newline at end of file diff --git a/resources/views/shelves/export.blade.php b/resources/views/shelves/export.blade.php new file mode 100644 index 000000000..462ad7991 --- /dev/null +++ b/resources/views/shelves/export.blade.php @@ -0,0 +1,80 @@ +<!doctype html> +<html lang="en"> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> + <title>{{ $book->name }}</title> + + <style> + @if (!app()->environment('testing')) + {!! file_get_contents(public_path('/dist/export-styles.css')) !!} + @endif + .page-break { + page-break-after: always; + } + .chapter-hint { + color: #888; + margin-top: 32px; + } + .chapter-hint + h1 { + margin-top: 0; + } + ul.contents ul li { + list-style: circle; + } + @media screen { + .page-break { + border-top: 1px solid #DDD; + } + } + </style> + @yield('head') +</head> +<body> +<div class="container"> + <div class="row"> + <div class="col-md-8 col-md-offset-2"> + <div class="page-content"> + + <h1 style="font-size: 4.8em">{{$book->name}}</h1> + + <p>{{ $book->description }}</p> + + @if(count($bookChildren) > 0) + <ul class="contents"> + @foreach($bookChildren as $bookChild) + <li><a href="#{{$bookChild->getType()}}-{{$bookChild->id}}">{{ $bookChild->name }}</a></li> + @if($bookChild->isA('chapter') && count($bookChild->pages) > 0) + <ul> + @foreach($bookChild->pages as $page) + <li><a href="#page-{{$page->id}}">{{ $page->name }}</a></li> + @endforeach + </ul> + @endif + @endforeach + </ul> + @endif + + @foreach($bookChildren as $bookChild) + <div class="page-break"></div> + <h1 id="{{$bookChild->getType()}}-{{$bookChild->id}}">{{ $bookChild->name }}</h1> + @if($bookChild->isA('chapter')) + <p>{{ $bookChild->description }}</p> + @if(count($bookChild->pages) > 0) + @foreach($bookChild->pages as $page) + <div class="page-break"></div> + <div class="chapter-hint">{{$bookChild->name}}</div> + <h1 id="page-{{$page->id}}">{{ $page->name }}</h1> + {!! $page->html !!} + @endforeach + @endif + @else + {!! $bookChild->html !!} + @endif + @endforeach + + </div> + </div> + </div> +</div> +</body> +</html> diff --git a/resources/views/shelves/form.blade.php b/resources/views/shelves/form.blade.php new file mode 100644 index 000000000..fb6fee115 --- /dev/null +++ b/resources/views/shelves/form.blade.php @@ -0,0 +1,84 @@ + +{{ csrf_field() }} +<div class="form-group title-input"> + <label for="name">{{ trans('common.name') }}</label> + @include('form/text', ['name' => 'name']) +</div> + +<div class="form-group description-input"> + <label for="description">{{ trans('common.description') }}</label> + @include('form/textarea', ['name' => 'description']) +</div> + +<div shelf-sort class="row"> + <div class="col-md-6"> + <div class="form-group"> + <label for="books">{{ trans('entities.shelves_books') }}</label> + <input type="hidden" id="books-input" name="books" + value="{{ isset($shelf) ? $shelf->books->implode('id', ',') : '' }}"> + <div class="scroll-box"> + <div class="scroll-box-item text-small text-muted instruction"> + {{ trans('entities.shelves_drag_books') }} + </div> + <div class="scroll-box-item scroll-box-placeholder" style="display: none;"> + <a href="#" class="text-muted">@icon('book') ...</a> + </div> + @if (isset($shelfBooks) && count($shelfBooks) > 0) + @foreach ($shelfBooks as $book) + <div data-id="{{ $book->id }}" class="scroll-box-item"> + <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a> + </div> + @endforeach + @endif + </div> + </div> + </div> + <div class="col-md-6"> + <div class="form-group"> + <label for="books">{{ trans('entities.shelves_add_books') }}</label> + <div class="scroll-box"> + @foreach ($books as $book) + <div data-id="{{ $book->id }}" class="scroll-box-item"> + <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a> + </div> + @endforeach + </div> + </div> + </div> +</div> + + + +<div class="form-group" collapsible id="logo-control"> + <div class="collapse-title text-primary" collapsible-trigger> + <label for="user-avatar">{{ trans('common.cover_image') }}</label> + </div> + <div class="collapse-content" collapsible-content> + <p class="small">{{ trans('common.cover_image_description') }}</p> + + @include('components.image-picker', [ + 'resizeHeight' => '512', + 'resizeWidth' => '512', + 'showRemove' => false, + 'defaultImage' => baseUrl('/book_default_cover.png'), + 'currentImage' => isset($shelf) ? $shelf->getBookCover() : baseUrl('/book_default_cover.png') , + 'currentId' => isset($shelf) && $shelf->image_id ? $shelf->image_id : 0, + 'name' => 'image_id', + 'imageClass' => 'cover' + ]) + </div> +</div> + +<div class="form-group" collapsible id="tags-control"> + <div class="collapse-title text-primary" collapsible-trigger> + <label for="tag-manager">{{ trans('entities.shelf_tags') }}</label> + </div> + <div class="collapse-content" collapsible-content> + @include('components.tag-manager', ['entity' => $shelf ?? null, 'entityType' => 'bookshelf']) + </div> +</div> + +<div class="form-group text-right"> + <a href="{{ isset($shelf) ? $shelf->getUrl() : baseUrl('/shelves') }}" class="button outline">{{ trans('common.cancel') }}</a> + <button type="submit" class="button pos">{{ trans('entities.shelves_save') }}</button> +</div> \ No newline at end of file diff --git a/resources/views/shelves/grid-item.blade.php b/resources/views/shelves/grid-item.blade.php new file mode 100644 index 000000000..b70b5166e --- /dev/null +++ b/resources/views/shelves/grid-item.blade.php @@ -0,0 +1,18 @@ +<div class="bookshelf-grid-item grid-card" data-entity-type="bookshelf" data-entity-id="{{$bookshelf->id}}"> + <div class="featured-image-container"> + <a href="{{$bookshelf->getUrl()}}" title="{{$bookshelf->name}}"> + <img src="{{$bookshelf->getBookCover()}}" alt="{{$bookshelf->name}}"> + </a> + </div> + <div class="grid-card-content"> + <h2><a class="break-text" href="{{$bookshelf->getUrl()}}" title="{{$bookshelf->name}}">{{$bookshelf->getShortName(35)}}</a></h2> + @if(isset($bookshelf->searchSnippet)) + <p >{!! $bookshelf->searchSnippet !!}</p> + @else + <p >{{ $bookshelf->getExcerpt(130) }}</p> + @endif + </div> + <div class="grid-card-footer text-muted text-small"> + <span>@include('partials.entity-meta', ['entity' => $bookshelf])</span> + </div> +</div> \ No newline at end of file diff --git a/resources/views/shelves/index.blade.php b/resources/views/shelves/index.blade.php new file mode 100644 index 000000000..a887a843e --- /dev/null +++ b/resources/views/shelves/index.blade.php @@ -0,0 +1,48 @@ +@extends('sidebar-layout') + +@section('toolbar') + <div class="col-xs-6 faded"> + <div class="action-buttons text-left"> + @include('shelves/view-toggle', ['shelvesViewType' => $shelvesViewType]) + </div> + </div> + <div class="col-xs-6 faded"> + <div class="action-buttons"> + @if($currentUser->can('bookshelf-create-all')) + <a href="{{ baseUrl("/create-shelf") }}" class="text-pos text-button">@icon('add'){{ trans('entities.shelves_create') }}</a> + @endif + </div> + </div> +@stop + +@section('sidebar') + @if($recents) + <div id="recents" class="card"> + <h3>@icon('view') {{ trans('entities.recently_viewed') }}</h3> + @include('partials/entity-list', ['entities' => $recents, 'style' => 'compact']) + </div> + @endif + + <div id="popular" class="card"> + <h3>@icon('popular') {{ trans('entities.shelves_popular') }}</h3> + @if(count($popular) > 0) + @include('partials/entity-list', ['entities' => $popular, 'style' => 'compact']) + @else + <div class="body text-muted">{{ trans('entities.shelves_popular_empty') }}</div> + @endif + </div> + + <div id="new" class="card"> + <h3>@icon('star-circle') {{ trans('entities.shelves_new') }}</h3> + @if(count($new) > 0) + @include('partials/entity-list', ['entities' => $new, 'style' => 'compact']) + @else + <div class="body text-muted">{{ trans('entities.shelves_new_empty') }}</div> + @endif + </div> +@stop + +@section('body') + @include('shelves/list', ['shelves' => $shelves, 'shelvesViewType' => $shelvesViewType]) + <p><br></p> +@stop \ No newline at end of file diff --git a/resources/views/shelves/list-item.blade.php b/resources/views/shelves/list-item.blade.php new file mode 100644 index 000000000..0b8e79fe5 --- /dev/null +++ b/resources/views/shelves/list-item.blade.php @@ -0,0 +1,10 @@ +<div class="shelf entity-list-item" data-entity-type="bookshelf" data-entity-id="{{$bookshelf->id}}"> + <h4 class="text-shelf"><a class="text-bookshelf entity-list-item-link" href="{{$bookshelf->getUrl()}}">@icon('bookshelf')<span class="entity-list-item-name break-text">{{$bookshelf->name}}</span></a></h4> + <div class="entity-item-snippet"> + @if(isset($bookshelf->searchSnippet)) + <p class="text-muted break-text">{!! $bookshelf->searchSnippet !!}</p> + @else + <p class="text-muted break-text">{{ $bookshelf->getExcerpt() }}</p> + @endif + </div> +</div> \ No newline at end of file diff --git a/resources/views/shelves/list.blade.php b/resources/views/shelves/list.blade.php new file mode 100644 index 000000000..ff11d2d67 --- /dev/null +++ b/resources/views/shelves/list.blade.php @@ -0,0 +1,26 @@ + +<div class="container{{ $shelvesViewType === 'list' ? ' small' : '' }}"> + <h1>{{ trans('entities.shelves') }}</h1> + @if(count($shelves) > 0) + @if($shelvesViewType === 'grid') + <div class="grid third"> + @foreach($shelves as $key => $shelf) + @include('shelves/grid-item', ['bookshelf' => $shelf]) + @endforeach + </div> + @else + @foreach($shelves as $shelf) + @include('shelves/list-item', ['bookshelf' => $shelf]) + <hr> + @endforeach + @endif + <div> + {!! $shelves->render() !!} + </div> + @else + <p class="text-muted">{{ trans('entities.shelves_empty') }}</p> + @if(userCan('bookshelf-create-all')) + <a href="{{ baseUrl("/create-shelf") }}" class="button outline">@icon('edit'){{ trans('entities.create_now') }}</a> + @endif + @endif +</div> \ No newline at end of file diff --git a/resources/views/shelves/restrictions.blade.php b/resources/views/shelves/restrictions.blade.php new file mode 100644 index 000000000..472078ad2 --- /dev/null +++ b/resources/views/shelves/restrictions.blade.php @@ -0,0 +1,34 @@ +@extends('simple-layout') + +@section('toolbar') + <div class="col-sm-12 faded"> + @include('shelves._breadcrumbs', ['shelf' => $shelf]) + </div> +@stop + +@section('body') + + <div class="container small"> + <p> </p> + <div class="card"> + <h3>@icon('lock') {{ trans('entities.shelves_permissions') }}</h3> + <div class="body"> + @include('form/restriction-form', ['model' => $shelf]) + </div> + </div> + + <p> </p> + + <div class="card"> + <h3>@icon('copy') {{ trans('entities.shelves_copy_permissions_to_books') }}</h3> + <div class="body"> + <p>{{ trans('entities.shelves_copy_permissions_explain') }}</p> + <form action="{{ $shelf->getUrl('/copy-permissions') }}" method="post" class="text-right"> + {{ csrf_field() }} + <button class="button">{{ trans('entities.shelves_copy_permissions') }}</button> + </form> + </div> + </div> + </div> + +@stop diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php new file mode 100644 index 000000000..2aae2c6ff --- /dev/null +++ b/resources/views/shelves/show.blade.php @@ -0,0 +1,88 @@ +@extends('sidebar-layout') + +@section('toolbar') + <div class="col-sm-6 col-xs-1 faded"> + @include('shelves._breadcrumbs', ['shelf' => $shelf]) + </div> + <div class="col-sm-6 col-xs-11"> + <div class="action-buttons faded"> + @if(userCan('bookshelf-update', $shelf)) + <a href="{{ $shelf->getUrl('/edit') }}" class="text-button text-primary">@icon('edit'){{ trans('common.edit') }}</a> + @endif + @if(userCan('restrictions-manage', $shelf) || userCan('bookshelf-delete', $shelf)) + <div dropdown class="dropdown-container"> + <a dropdown-toggle class="text-primary text-button">@icon('more'){{ trans('common.more') }}</a> + <ul> + @if(userCan('restrictions-manage', $shelf)) + <li><a href="{{ $shelf->getUrl('/permissions') }}" class="text-primary">@icon('lock'){{ trans('entities.permissions') }}</a></li> + @endif + @if(userCan('bookshelf-delete', $shelf)) + <li><a href="{{ $shelf->getUrl('/delete') }}" class="text-neg">@icon('delete'){{ trans('common.delete') }}</a></li> + @endif + </ul> + </div> + @endif + </div> + </div> +@stop + +@section('sidebar') + + @if($shelf->tags->count() > 0) + <section> + @include('components.tag-list', ['entity' => $shelf]) + </section> + @endif + + <div class="card entity-details"> + <h3>@icon('info') {{ trans('common.details') }}</h3> + <div class="body text-small text-muted blended-links"> + @include('partials.entity-meta', ['entity' => $shelf]) + @if($shelf->restricted) + <div class="active-restriction"> + @if(userCan('restrictions-manage', $shelf)) + <a href="{{ $shelf->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.shelves_permissions_active') }}</a> + @else + @icon('lock'){{ trans('entities.shelves_permissions_active') }} + @endif + </div> + @endif + </div> + </div> + + @if(count($activity) > 0) + <div class="activity card"> + <h3>@icon('time') {{ trans('entities.recent_activity') }}</h3> + @include('partials/activity-list', ['activity' => $activity]) + </div> + @endif +@stop + +@section('body') + + <div class="container small nopad"> + <h1 class="break-text">{{$shelf->name}}</h1> + <div class="book-content"> + <p class="text-muted">{!! nl2br(e($shelf->description)) !!}</p> + @if(count($books) > 0) + <div class="page-list"> + <hr> + @foreach($books as $book) + @include('books/list-item', ['book' => $book]) + <hr> + @endforeach + </div> + @else + <p> + <hr> + <span class="text-muted italic">{{ trans('entities.shelves_empty_contents') }}</span> + @if(userCan('bookshelf-create', $shelf)) + <br> + <a href="{{ $shelf->getUrl('/edit') }}" class="button outline bookshelf">{{ trans('entities.shelves_edit_and_assign') }}</a> + @endif + </p> + @endif + + </div> + +@stop diff --git a/resources/views/shelves/view-toggle.blade.php b/resources/views/shelves/view-toggle.blade.php new file mode 100644 index 000000000..785e8cac1 --- /dev/null +++ b/resources/views/shelves/view-toggle.blade.php @@ -0,0 +1,10 @@ +<form action="{{ baseUrl("/settings/users/{$currentUser->id}/switch-shelf-view") }}" method="POST" class="inline"> + {!! csrf_field() !!} + {!! method_field('PATCH') !!} + <input type="hidden" value="{{ $shelvesViewType === 'list'? 'grid' : 'list' }}" name="view_type"> + @if ($shelvesViewType === 'list') + <button type="submit" class="text-pos text-button">@icon('grid'){{ trans('common.grid_view') }}</button> + @else + <button type="submit" class="text-pos text-button">@icon('list'){{ trans('common.list_view') }}</button> + @endif +</form> \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 70cb3c130..d3c5f46d3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,6 +14,21 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/recently-updated', 'PageController@showRecentlyUpdated'); }); + // Shelves + Route::get('/create-shelf', 'BookshelfController@create'); + Route::group(['prefix' => 'shelves'], function() { + Route::get('/', 'BookshelfController@index'); + Route::post('/', 'BookshelfController@store'); + Route::get('/{slug}/edit', 'BookshelfController@edit'); + Route::get('/{slug}/delete', 'BookshelfController@showDelete'); + Route::get('/{slug}', 'BookshelfController@show'); + Route::put('/{slug}', 'BookshelfController@update'); + Route::delete('/{slug}', 'BookshelfController@destroy'); + Route::get('/{slug}/permissions', 'BookshelfController@showRestrict'); + Route::put('/{slug}/permissions', 'BookshelfController@restrict'); + Route::post('/{slug}/copy-permissions', 'BookshelfController@copyPermissions'); + }); + Route::get('/create-book', 'BookController@create'); Route::group(['prefix' => 'books'], function () { @@ -160,6 +175,7 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/users/create', 'UserController@create'); Route::get('/users/{id}/delete', 'UserController@delete'); Route::patch('/users/{id}/switch-book-view', 'UserController@switchBookView'); + Route::patch('/users/{id}/switch-shelf-view', 'UserController@switchShelfView'); Route::post('/users/create', 'UserController@store'); Route::get('/users/{id}', 'UserController@edit'); Route::put('/users/{id}', 'UserController@update'); diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php new file mode 100644 index 000000000..9071e3c06 --- /dev/null +++ b/tests/Entity/BookShelfTest.php @@ -0,0 +1,170 @@ +<?php namespace Tests; + +use BookStack\Book; +use BookStack\Bookshelf; + +class BookShelfTest extends TestCase +{ + + public function test_shelves_shows_in_header_if_have_view_permissions() + { + $viewer = $this->getViewer(); + $resp = $this->actingAs($viewer)->get('/'); + $resp->assertElementContains('header', 'Shelves'); + + $viewer->roles()->delete(); + $this->giveUserPermissions($viewer); + $resp = $this->actingAs($viewer)->get('/'); + $resp->assertElementNotContains('header', 'Shelves'); + + $this->giveUserPermissions($viewer, ['bookshelf-view-all']); + $resp = $this->actingAs($viewer)->get('/'); + $resp->assertElementContains('header', 'Shelves'); + + $viewer->roles()->delete(); + $this->giveUserPermissions($viewer, ['bookshelf-view-own']); + $resp = $this->actingAs($viewer)->get('/'); + $resp->assertElementContains('header', 'Shelves'); + } + + public function test_shelves_page_contains_create_link() + { + $resp = $this->asEditor()->get('/shelves'); + $resp->assertElementContains('a', 'Create New Shelf'); + } + + public function test_shelves_create() + { + $booksToInclude = Book::take(2)->get(); + $shelfInfo = [ + 'name' => 'My test book' . str_random(4), + 'description' => 'Test book description ' . str_random(10) + ]; + $resp = $this->asEditor()->post('/shelves', array_merge($shelfInfo, [ + 'books' => $booksToInclude->implode('id', ','), + 'tags' => [ + [ + 'name' => 'Test Category', + 'value' => 'Test Tag Value', + ] + ], + ])); + $resp->assertRedirect(); + $editorId = $this->getEditor()->id; + $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); + + $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first(); + $shelfPage = $this->get($shelf->getUrl()); + $shelfPage->assertSee($shelfInfo['name']); + $shelfPage->assertSee($shelfInfo['description']); + $shelfPage->assertElementContains('.tag-item', 'Test Category'); + $shelfPage->assertElementContains('.tag-item', 'Test Tag Value'); + + $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[0]->id]); + $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]); + } + + public function test_shelf_view() + { + $shelf = Bookshelf::first(); + $resp = $this->asEditor()->get($shelf->getUrl()); + $resp->assertStatus(200); + $resp->assertSeeText($shelf->name); + $resp->assertSeeText($shelf->description); + + foreach ($shelf->books as $book) { + $resp->assertSee($book->name); + } + } + + public function test_shelf_view_shows_action_buttons() + { + $shelf = Bookshelf::first(); + $resp = $this->asAdmin()->get($shelf->getUrl()); + $resp->assertSee($shelf->getUrl('/edit')); + $resp->assertSee($shelf->getUrl('/permissions')); + $resp->assertSee($shelf->getUrl('/delete')); + $resp->assertElementContains('a', 'Edit'); + $resp->assertElementContains('a', 'Permissions'); + $resp->assertElementContains('a', 'Delete'); + + $resp = $this->asEditor()->get($shelf->getUrl()); + $resp->assertDontSee($shelf->getUrl('/permissions')); + } + + public function test_shelf_edit() + { + $shelf = Bookshelf::first(); + $resp = $this->asEditor()->get($shelf->getUrl('/edit')); + $resp->assertSeeText('Edit Bookshelf'); + + $booksToInclude = Book::take(2)->get(); + $shelfInfo = [ + 'name' => 'My test book' . str_random(4), + 'description' => 'Test book description ' . str_random(10) + ]; + + $resp = $this->asEditor()->put($shelf->getUrl(), array_merge($shelfInfo, [ + 'books' => $booksToInclude->implode('id', ','), + 'tags' => [ + [ + 'name' => 'Test Category', + 'value' => 'Test Tag Value', + ] + ], + ])); + $shelf = Bookshelf::find($shelf->id); + $resp->assertRedirect($shelf->getUrl()); + $this->assertSessionHas('success'); + + $editorId = $this->getEditor()->id; + $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); + + $shelfPage = $this->get($shelf->getUrl()); + $shelfPage->assertSee($shelfInfo['name']); + $shelfPage->assertSee($shelfInfo['description']); + $shelfPage->assertElementContains('.tag-item', 'Test Category'); + $shelfPage->assertElementContains('.tag-item', 'Test Tag Value'); + + $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[0]->id]); + $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]); + } + + public function test_shelf_delete() + { + $shelf = Bookshelf::first(); + $resp = $this->asEditor()->get($shelf->getUrl('/delete')); + $resp->assertSeeText('Delete Bookshelf'); + $resp->assertSee("action=\"{$shelf->getUrl()}\""); + + $resp = $this->delete($shelf->getUrl()); + $resp->assertRedirect('/shelves'); + $this->assertDatabaseMissing('bookshelves', ['id' => $shelf->id]); + $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]); + $this->assertSessionHas('success'); + } + + public function test_shelf_copy_permissions() + { + $shelf = Bookshelf::first(); + $resp = $this->asAdmin()->get($shelf->getUrl('/permissions')); + $resp->assertSeeText('Copy Permissions'); + $resp->assertSee("action=\"{$shelf->getUrl('/copy-permissions')}\""); + + $child = $shelf->books()->first(); + $editorRole = $this->getEditor()->roles()->first(); + $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default"); + $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default"); + + $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]); + $resp = $this->post($shelf->getUrl('/copy-permissions')); + $child = $shelf->books()->first(); + + $resp->assertRedirect($shelf->getUrl()); + $this->assertTrue(boolval($child->restricted), "Child book should now be restricted"); + $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions"); + $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]); + $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]); + } + +} diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php index c9b5a0109..a5e4a4a5e 100644 --- a/tests/ErrorTest.php +++ b/tests/ErrorTest.php @@ -9,10 +9,13 @@ class ErrorTest extends TestCase // if our custom, middleware-loaded handler fails but this is here // as a reminder and as a general check in the event of other issues. $editor = $this->getEditor(); + $editor->name = 'tester'; + $editor->save(); + $this->actingAs($editor); $notFound = $this->get('/fgfdngldfnotfound'); $notFound->assertStatus(404); $notFound->assertDontSeeText('Log in'); - $notFound->assertSeeText($editor->getShortName(9)); + $notFound->assertSeeText('tester'); } } \ No newline at end of file diff --git a/tests/HomepageTest.php b/tests/HomepageTest.php index 29e0985c3..86cae7893 100644 --- a/tests/HomepageTest.php +++ b/tests/HomepageTest.php @@ -10,15 +10,17 @@ class HomepageTest extends TestCase $homeVisit->assertSee('My Recently Viewed'); $homeVisit->assertSee('Recently Updated Pages'); $homeVisit->assertSee('Recent Activity'); + $homeVisit->assertSee('home-default'); } public function test_custom_homepage() { $this->asEditor(); $name = 'My custom homepage'; - $content = 'This is the body content of my custom homepage.'; + $content = str_repeat('This is the body content of my custom homepage.', 20); $customPage = $this->newPage(['name' => $name, 'html' => $content]); $this->setSettings(['app-homepage' => $customPage->id]); + $this->setSettings(['app-homepage-type' => 'page']); $homeVisit = $this->get('/'); $homeVisit->assertSee($name); @@ -32,7 +34,7 @@ class HomepageTest extends TestCase { $this->asEditor(); $name = 'My custom homepage'; - $content = 'This is the body content of my custom homepage.'; + $content = str_repeat('This is the body content of my custom homepage.', 20); $customPage = $this->newPage(['name' => $name, 'html' => $content]); $this->setSettings(['app-homepage' => $customPage->id]); @@ -55,7 +57,7 @@ class HomepageTest extends TestCase $editor = $this->getEditor(); setting()->putUser($editor, 'books_view_type', 'grid'); - $this->setSettings(['app-book-homepage' => true]); + $this->setSettings(['app-homepage-type' => 'books']); $this->asEditor(); $homeVisit = $this->get('/'); @@ -65,7 +67,26 @@ class HomepageTest extends TestCase $homeVisit->assertSee('grid-card-footer'); $homeVisit->assertSee('featured-image-container'); - $this->setSettings(['app-book-homepage' => false]); + $this->setSettings(['app-homepage-type' => false]); + $this->test_default_homepage_visible(); + } + + public function test_set_bookshelves_homepage() + { + $editor = $this->getEditor(); + setting()->putUser($editor, 'bookshelves_view_type', 'grid'); + + $this->setSettings(['app-homepage-type' => 'bookshelves']); + + $this->asEditor(); + $homeVisit = $this->get('/'); + $homeVisit->assertSee('Shelves'); + $homeVisit->assertSee('bookshelf-grid-item grid-card'); + $homeVisit->assertSee('grid-card-content'); + $homeVisit->assertSee('grid-card-footer'); + $homeVisit->assertSee('featured-image-container'); + + $this->setSettings(['app-homepage-type' => false]); $this->test_default_homepage_visible(); } } diff --git a/tests/Permissions/RestrictionsTest.php b/tests/Permissions/RestrictionsTest.php index 2bbb1a5fa..540125fd1 100644 --- a/tests/Permissions/RestrictionsTest.php +++ b/tests/Permissions/RestrictionsTest.php @@ -1,6 +1,7 @@ <?php namespace Tests; use BookStack\Book; +use BookStack\Bookshelf; use BookStack\Entity; use BookStack\User; use BookStack\Repos\EntityRepo; @@ -34,6 +35,63 @@ class RestrictionsTest extends BrowserKitTest parent::setEntityRestrictions($entity, $actions, $roles); } + public function test_bookshelf_view_restriction() + { + $shelf = Bookshelf::first(); + + $this->actingAs($this->user) + ->visit($shelf->getUrl()) + ->seePageIs($shelf->getUrl()); + + $this->setEntityRestrictions($shelf, []); + + $this->forceVisit($shelf->getUrl()) + ->see('Bookshelf not found'); + + $this->setEntityRestrictions($shelf, ['view']); + + $this->visit($shelf->getUrl()) + ->see($shelf->name); + } + + public function test_bookshelf_update_restriction() + { + $shelf = BookShelf::first(); + + $this->actingAs($this->user) + ->visit($shelf->getUrl('/edit')) + ->see('Edit Book'); + + $this->setEntityRestrictions($shelf, ['view', 'delete']); + + $this->forceVisit($shelf->getUrl('/edit')) + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($shelf, ['view', 'update']); + + $this->visit($shelf->getUrl('/edit')) + ->seePageIs($shelf->getUrl('/edit')); + } + + public function test_bookshelf_delete_restriction() + { + $shelf = Book::first(); + + $this->actingAs($this->user) + ->visit($shelf->getUrl('/delete')) + ->see('Delete Book'); + + $this->setEntityRestrictions($shelf, ['view', 'update']); + + $this->forceVisit($shelf->getUrl('/delete')) + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($shelf, ['view', 'delete']); + + $this->visit($shelf->getUrl('/delete')) + ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book'); + } + public function test_book_view_restriction() { $book = Book::first(); @@ -325,6 +383,23 @@ class RestrictionsTest extends BrowserKitTest ->seePageIs($pageUrl . '/delete')->see('Delete Page'); } + public function test_bookshelf_restriction_form() + { + $shelf = Bookshelf::first(); + $this->asAdmin()->visit($shelf->getUrl('/permissions')) + ->see('Bookshelf Permissions') + ->check('restricted') + ->check('restrictions[2][view]') + ->press('Save Permissions') + ->seeInDatabase('bookshelves', ['id' => $shelf->id, 'restricted' => true]) + ->seeInDatabase('entity_permissions', [ + 'restrictable_id' => $shelf->id, + 'restrictable_type' => 'BookStack\Bookshelf', + 'role_id' => '2', + 'action' => 'view' + ]); + } + public function test_book_restriction_form() { $book = Book::first(); @@ -413,6 +488,44 @@ class RestrictionsTest extends BrowserKitTest ->dontSee($page->name); } + public function test_bookshelf_update_restriction_override() + { + $shelf = Bookshelf::first(); + + $this->actingAs($this->viewer) + ->visit($shelf->getUrl('/edit')) + ->dontSee('Edit Book'); + + $this->setEntityRestrictions($shelf, ['view', 'delete']); + + $this->forceVisit($shelf->getUrl('/edit')) + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($shelf, ['view', 'update']); + + $this->visit($shelf->getUrl('/edit')) + ->seePageIs($shelf->getUrl('/edit')); + } + + public function test_bookshelf_delete_restriction_override() + { + $shelf = Bookshelf::first(); + + $this->actingAs($this->viewer) + ->visit($shelf->getUrl('/delete')) + ->dontSee('Delete Book'); + + $this->setEntityRestrictions($shelf, ['view', 'update']); + + $this->forceVisit($shelf->getUrl('/delete')) + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($shelf, ['view', 'delete']); + + $this->visit($shelf->getUrl('/delete')) + ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book'); + } + public function test_book_create_restriction_override() { $book = Book::first(); diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php index f076e6734..e0f827d02 100644 --- a/tests/Permissions/RolesTest.php +++ b/tests/Permissions/RolesTest.php @@ -1,5 +1,6 @@ <?php namespace Tests; +use BookStack\Bookshelf; use BookStack\Page; use BookStack\Repos\PermissionsRepo; use BookStack\Role; @@ -16,32 +17,6 @@ class RolesTest extends BrowserKitTest $this->user = $this->getViewer(); } - /** - * Give the given user some permissions. - * @param \BookStack\User $user - * @param array $permissions - */ - protected function giveUserPermissions(\BookStack\User $user, $permissions = []) - { - $newRole = $this->createNewRole($permissions); - $user->attachRole($newRole); - $user->load('roles'); - $user->permissions(false); - } - - /** - * Create a new basic role for testing purposes. - * @param array $permissions - * @return Role - */ - protected function createNewRole($permissions = []) - { - $permissionRepo = app(PermissionsRepo::class); - $roleData = factory(\BookStack\Role::class)->make()->toArray(); - $roleData['permissions'] = array_flip($permissions); - return $permissionRepo->saveNewRole($roleData); - } - public function test_admin_can_see_settings() { $this->asAdmin()->visit('/settings')->see('Settings'); @@ -203,6 +178,90 @@ class RolesTest extends BrowserKitTest } } + public function test_bookshelves_create_all_permissions() + { + $this->checkAccessPermission('bookshelf-create-all', [ + '/create-shelf' + ], [ + '/shelves' => 'Create New Shelf' + ]); + + $this->visit('/create-shelf') + ->type('test shelf', 'name') + ->type('shelf desc', 'description') + ->press('Save Shelf') + ->seePageIs('/shelves/test-shelf'); + } + + public function test_bookshelves_edit_own_permission() + { + $otherShelf = Bookshelf::first(); + $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']); + $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save(); + $this->regenEntityPermissions($ownShelf); + + $this->checkAccessPermission('bookshelf-update-own', [ + $ownShelf->getUrl('/edit') + ], [ + $ownShelf->getUrl() => 'Edit' + ]); + + $this->visit($otherShelf->getUrl()) + ->dontSeeInElement('.action-buttons', 'Edit') + ->visit($otherShelf->getUrl('/edit')) + ->seePageIs('/'); + } + + public function test_bookshelves_edit_all_permission() + { + $otherShelf = \BookStack\Bookshelf::first(); + $this->checkAccessPermission('bookshelf-update-all', [ + $otherShelf->getUrl('/edit') + ], [ + $otherShelf->getUrl() => 'Edit' + ]); + } + + public function test_bookshelves_delete_own_permission() + { + $this->giveUserPermissions($this->user, ['bookshelf-update-all']); + $otherShelf = \BookStack\Bookshelf::first(); + $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']); + $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save(); + $this->regenEntityPermissions($ownShelf); + + $this->checkAccessPermission('bookshelf-delete-own', [ + $ownShelf->getUrl('/delete') + ], [ + $ownShelf->getUrl() => 'Delete' + ]); + + $this->visit($otherShelf->getUrl()) + ->dontSeeInElement('.action-buttons', 'Delete') + ->visit($otherShelf->getUrl('/delete')) + ->seePageIs('/'); + $this->visit($ownShelf->getUrl())->visit($ownShelf->getUrl('/delete')) + ->press('Confirm') + ->seePageIs('/shelves') + ->dontSee($ownShelf->name); + } + + public function test_bookshelves_delete_all_permission() + { + $this->giveUserPermissions($this->user, ['bookshelf-update-all']); + $otherShelf = \BookStack\Bookshelf::first(); + $this->checkAccessPermission('bookshelf-delete-all', [ + $otherShelf->getUrl('/delete') + ], [ + $otherShelf->getUrl() => 'Delete' + ]); + + $this->visit($otherShelf->getUrl())->visit($otherShelf->getUrl('/delete')) + ->press('Confirm') + ->seePageIs('/shelves') + ->dontSee($otherShelf->name); + } + public function test_books_create_all_permissions() { $this->checkAccessPermission('book-create-all', [ diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index 325979e74..581dac5f1 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -1,9 +1,11 @@ <?php namespace Tests; use BookStack\Book; +use BookStack\Bookshelf; use BookStack\Chapter; use BookStack\Entity; use BookStack\Repos\EntityRepo; +use BookStack\Repos\PermissionsRepo; use BookStack\Role; use BookStack\Services\PermissionService; use BookStack\Services\SettingService; @@ -69,6 +71,25 @@ trait SharedTestHelpers return $user; } + /** + * Regenerate the permission for an entity. + * @param Entity $entity + */ + protected function regenEntityPermissions(Entity $entity) + { + $this->app[PermissionService::class]->buildJointPermissionsForEntity($entity); + $entity->load('jointPermissions'); + } + + /** + * Create and return a new bookshelf. + * @param array $input + * @return Bookshelf + */ + public function newShelf($input = ['name' => 'test shelf', 'description' => 'My new test shelf']) { + return $this->app[EntityRepo::class]->createFromInput('bookshelf', $input, false); + } + /** * Create and return a new book. * @param array $input @@ -140,4 +161,30 @@ trait SharedTestHelpers $entity->load('jointPermissions'); } + /** + * Give the given user some permissions. + * @param \BookStack\User $user + * @param array $permissions + */ + protected function giveUserPermissions(\BookStack\User $user, $permissions = []) + { + $newRole = $this->createNewRole($permissions); + $user->attachRole($newRole); + $user->load('roles'); + $user->permissions(false); + } + + /** + * Create a new basic role for testing purposes. + * @param array $permissions + * @return Role + */ + protected function createNewRole($permissions = []) + { + $permissionRepo = app(PermissionsRepo::class); + $roleData = factory(Role::class)->make()->toArray(); + $roleData['permissions'] = array_flip($permissions); + return $permissionRepo->saveNewRole($roleData); + } + } \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index e0f160eed..939a1a91e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,13 +2,13 @@ use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; -use Illuminate\Foundation\Testing\TestResponse; abstract class TestCase extends BaseTestCase { use CreatesApplication; use DatabaseTransactions; use SharedTestHelpers; + /** * The base URL to use while testing the application. * @var string @@ -18,11 +18,46 @@ abstract class TestCase extends BaseTestCase /** * Assert a permission error has occurred. * @param TestResponse $response + * @return TestCase */ protected function assertPermissionError(TestResponse $response) { $response->assertRedirect('/'); - $this->assertTrue(session()->has('error')); + $this->assertSessionHas('error'); session()->remove('error'); + return $this; + } + + /** + * Assert the session contains a specific entry. + * @param string $key + * @return $this + */ + protected function assertSessionHas(string $key) + { + $this->assertTrue(session()->has($key), "Session does not contain a [{$key}] entry"); + return $this; + } + + /** + * Override of the get method so we can get visibility of custom TestResponse methods. + * @param string $uri + * @param array $headers + * @return TestResponse + */ + public function get($uri, array $headers = []) + { + return parent::get($uri, $headers); + } + + /** + * Create the test response instance from the given response. + * + * @param \Illuminate\Http\Response $response + * @return TestResponse + */ + protected function createTestResponse($response) + { + return TestResponse::fromBaseResponse($response); } } \ No newline at end of file diff --git a/tests/TestResponse.php b/tests/TestResponse.php new file mode 100644 index 000000000..a68a5783f --- /dev/null +++ b/tests/TestResponse.php @@ -0,0 +1,141 @@ +<?php namespace Tests; + +use \Illuminate\Foundation\Testing\TestResponse as BaseTestResponse; +use Symfony\Component\DomCrawler\Crawler; +use PHPUnit\Framework\Assert as PHPUnit; + +/** + * Class TestResponse + * Custom extension of the default Laravel TestResponse class. + * @package Tests + */ +class TestResponse extends BaseTestResponse { + + protected $crawlerInstance; + + /** + * Get the DOM Crawler for the response content. + * @return Crawler + */ + protected function crawler() + { + if (!is_object($this->crawlerInstance)) { + $this->crawlerInstance = new Crawler($this->getContent()); + } + return $this->crawlerInstance; + } + + /** + * Assert the response contains the specified element. + * @param string $selector + * @return $this + */ + public function assertElementExists(string $selector) + { + $elements = $this->crawler()->filter($selector); + PHPUnit::assertTrue( + $elements->count() > 0, + 'Unable to find element matching the selector: '.PHP_EOL.PHP_EOL. + "[{$selector}]".PHP_EOL.PHP_EOL. + 'within'.PHP_EOL.PHP_EOL. + "[{$this->getContent()}]." + ); + return $this; + } + + /** + * Assert the response does not contain the specified element. + * @param string $selector + * @return $this + */ + public function assertElementNotExists(string $selector) + { + $elements = $this->crawler()->filter($selector); + PHPUnit::assertTrue( + $elements->count() === 0, + 'Found elements matching the selector: '.PHP_EOL.PHP_EOL. + "[{$selector}]".PHP_EOL.PHP_EOL. + 'within'.PHP_EOL.PHP_EOL. + "[{$this->getContent()}]." + ); + return $this; + } + + /** + * Assert the response includes a specific element containing the given text. + * @param string $selector + * @param string $text + * @return $this + */ + public function assertElementContains(string $selector, string $text) + { + $elements = $this->crawler()->filter($selector); + $matched = false; + $pattern = $this->getEscapedPattern($text); + foreach ($elements as $element) { + $element = new Crawler($element); + if (preg_match("/$pattern/i", $element->html())) { + $matched = true; + break; + } + } + + PHPUnit::assertTrue( + $matched, + 'Unable to find element of selector: '.PHP_EOL.PHP_EOL. + "[{$selector}]".PHP_EOL.PHP_EOL. + 'containing text'.PHP_EOL.PHP_EOL. + "[{$text}]".PHP_EOL.PHP_EOL. + 'within'.PHP_EOL.PHP_EOL. + "[{$this->getContent()}]." + ); + + return $this; + } + + /** + * Assert the response does not include a specific element containing the given text. + * @param string $selector + * @param string $text + * @return $this + */ + public function assertElementNotContains(string $selector, string $text) + { + $elements = $this->crawler()->filter($selector); + $matched = false; + $pattern = $this->getEscapedPattern($text); + foreach ($elements as $element) { + $element = new Crawler($element); + if (preg_match("/$pattern/i", $element->html())) { + $matched = true; + break; + } + } + + PHPUnit::assertTrue( + !$matched, + 'Found element of selector: '.PHP_EOL.PHP_EOL. + "[{$selector}]".PHP_EOL.PHP_EOL. + 'containing text'.PHP_EOL.PHP_EOL. + "[{$text}]".PHP_EOL.PHP_EOL. + 'within'.PHP_EOL.PHP_EOL. + "[{$this->getContent()}]." + ); + + return $this; + } + + /** + * Get the escaped text pattern for the constraint. + * @param string $text + * @return string + */ + protected function getEscapedPattern($text) + { + $rawPattern = preg_quote($text, '/'); + $escapedPattern = preg_quote(e($text), '/'); + return $rawPattern == $escapedPattern + ? $rawPattern : "({$rawPattern}|{$escapedPattern})"; + } + +}