Merge pull request #947 from BookStackApp/bookshelves

Bookshelves
This commit is contained in:
Dan Brown 2018-09-21 15:29:52 +01:00 committed by GitHub
commit 131fcae4c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 2100 additions and 210 deletions

View File

@ -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

84
app/Bookshelf.php Normal file
View File

@ -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";
}
}

View File

@ -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;

View File

@ -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());
}
}

View File

@ -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);
}
/**

View File

@ -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");
}
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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

View File

@ -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,

View File

@ -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();
}
/**

View File

@ -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();
}
}

View File

@ -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();
}

15
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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>

After

Width:  |  Height:  |  Size: 375 B

View File

@ -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;

View File

@ -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 = {};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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',
];

View File

@ -52,6 +52,7 @@ return [
'details' => 'Details',
'grid_view' => 'Grid View',
'list_view' => 'List View',
'default' => 'Default',
/**
* Header

View File

@ -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)',

View File

@ -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',

View File

@ -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',

View File

@ -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>

View File

@ -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>

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -10,7 +10,7 @@
@section('body')
<div class="container">
<div class="container" id="home-default">
<div class="row">
<div class="col-sm-4">

View File

@ -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)

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,3 @@
<div class="breadcrumbs">
<a href="{{$shelf->getUrl()}}" class="text-bookshelf text-button">@icon('bookshelf'){{ $shelf->getShortName() }}</a>
</div>

View File

@ -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">&raquo;</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>&nbsp;</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

View File

@ -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>&nbsp;</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

View File

@ -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>&nbsp;</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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>&nbsp;</p>
<div class="card">
<h3>@icon('lock') {{ trans('entities.shelves_permissions') }}</h3>
<div class="body">
@include('form/restriction-form', ['model' => $shelf])
</div>
</div>
<p>&nbsp;</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

View File

@ -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

View File

@ -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>

View File

@ -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');

View File

@ -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]);
}
}

View File

@ -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');
}
}

View File

@ -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();
}
}

View File

@ -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();

View File

@ -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', [

View File

@ -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);
}
}

View File

@ -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);
}
}

141
tests/TestResponse.php Normal file
View File

@ -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})";
}
}