]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #986 from DeehSlash/fix/pt_br_locale
authorDan Brown <redacted>
Sat, 22 Sep 2018 13:40:23 +0000 (14:40 +0100)
committerGitHub <redacted>
Sat, 22 Sep 2018 13:40:23 +0000 (14:40 +0100)
Adds and fixes pt_BR strings

120 files changed:
app/Book.php
app/Bookshelf.php [new file with mode: 0644]
app/Console/Commands/CleanupImages.php
app/Entity.php
app/Exceptions/SocialSignInAccountNotUsed.php [new file with mode: 0644]
app/Http/Controllers/AttachmentController.php
app/Http/Controllers/Auth/RegisterController.php
app/Http/Controllers/BookController.php
app/Http/Controllers/BookshelfController.php [new file with mode: 0644]
app/Http/Controllers/ChapterController.php
app/Http/Controllers/Controller.php
app/Http/Controllers/HomeController.php
app/Http/Controllers/PageController.php
app/Http/Controllers/UserController.php
app/Http/Middleware/Localization.php
app/Image.php
app/Page.php
app/Repos/EntityRepo.php
app/Repos/PermissionsRepo.php
app/Repos/UserRepo.php
app/Services/ImageService.php
app/Services/LdapService.php
app/Services/PermissionService.php
app/Services/SocialAuthService.php
config/app.php
config/services.php
config/session.php
database/factories/ModelFactory.php
database/migrations/2016_04_20_192649_create_joint_permissions_table.php
database/migrations/2018_08_04_115700_create_bookshelves_table.php [new file with mode: 0644]
database/seeds/DummyContentSeeder.php
package-lock.json
package.json
phpunit.xml
resources/assets/icons/bookshelf.svg [new file with mode: 0644]
resources/assets/js/components/homepage-control.js [new file with mode: 0644]
resources/assets/js/components/index.js
resources/assets/js/components/markdown-editor.js
resources/assets/js/components/page-picker.js
resources/assets/js/components/shelf-sort.js [new file with mode: 0644]
resources/assets/js/components/wysiwyg-editor.js
resources/assets/js/services/code.js
resources/assets/sass/_grid.scss
resources/assets/sass/_lists.scss
resources/assets/sass/_tables.scss
resources/assets/sass/_text.scss
resources/assets/sass/_variables.scss
resources/assets/sass/styles.scss
resources/lang/ar/activities.php [new file with mode: 0644]
resources/lang/ar/auth.php [new file with mode: 0644]
resources/lang/ar/common.php [new file with mode: 0644]
resources/lang/ar/components.php [new file with mode: 0644]
resources/lang/ar/entities.php [new file with mode: 0644]
resources/lang/ar/errors.php [new file with mode: 0644]
resources/lang/ar/pagination.php [new file with mode: 0644]
resources/lang/ar/passwords.php [new file with mode: 0644]
resources/lang/ar/settings.php [new file with mode: 0755]
resources/lang/ar/validation.php [new file with mode: 0644]
resources/lang/de/entities.php
resources/lang/en/activities.php
resources/lang/en/common.php
resources/lang/en/entities.php
resources/lang/en/errors.php
resources/lang/en/settings.php
resources/lang/es/entities.php
resources/lang/es_AR/entities.php
resources/lang/fr/entities.php
resources/lang/it/entities.php
resources/lang/ja/entities.php
resources/lang/nl/entities.php
resources/lang/pl/entities.php
resources/lang/pt_BR/entities.php
resources/lang/ru/entities.php
resources/lang/sk/entities.php
resources/lang/sv/entities.php
resources/lang/zh_CN/entities.php
resources/lang/zh_TW/entities.php
resources/views/base.blade.php
resources/views/books/export.blade.php
resources/views/books/list.blade.php
resources/views/books/show.blade.php
resources/views/books/view-toggle.blade.php
resources/views/chapters/export.blade.php
resources/views/common/home-shelves.blade.php [new file with mode: 0644]
resources/views/common/home.blade.php
resources/views/pages/export.blade.php
resources/views/pages/form.blade.php
resources/views/pages/page-display.blade.php
resources/views/pages/revisions.blade.php
resources/views/partials/custom-head.blade.php [new file with mode: 0644]
resources/views/partials/custom-styles.blade.php
resources/views/partials/entity-list.blade.php
resources/views/settings/index.blade.php
resources/views/settings/roles/form.blade.php
resources/views/shelves/_breadcrumbs.blade.php [new file with mode: 0644]
resources/views/shelves/create.blade.php [new file with mode: 0644]
resources/views/shelves/delete.blade.php [new file with mode: 0644]
resources/views/shelves/edit.blade.php [new file with mode: 0644]
resources/views/shelves/export.blade.php [new file with mode: 0644]
resources/views/shelves/form.blade.php [new file with mode: 0644]
resources/views/shelves/grid-item.blade.php [new file with mode: 0644]
resources/views/shelves/index.blade.php [new file with mode: 0644]
resources/views/shelves/list-item.blade.php [new file with mode: 0644]
resources/views/shelves/list.blade.php [new file with mode: 0644]
resources/views/shelves/restrictions.blade.php [new file with mode: 0644]
resources/views/shelves/show.blade.php [new file with mode: 0644]
resources/views/shelves/view-toggle.blade.php [new file with mode: 0644]
routes/web.php
tests/Auth/SocialAuthTest.php
tests/Entity/BookShelfTest.php [new file with mode: 0644]
tests/Entity/ExportTest.php
tests/Entity/PageRevisionTest.php
tests/ErrorTest.php
tests/HomepageTest.php
tests/LanguageTest.php
tests/Permissions/RestrictionsTest.php
tests/Permissions/RolesTest.php
tests/SharedTestHelpers.php
tests/TestCase.php
tests/TestResponse.php [new file with mode: 0644]

index 51ea226b47cda9b9df027e922db2d4e1dc6995a2..4e944ce1088fb1254831f6537df58d2edd2f4544 100644 (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
diff --git a/app/Bookshelf.php b/app/Bookshelf.php
new file mode 100644 (file)
index 0000000..9468575
--- /dev/null
@@ -0,0 +1,83 @@
+<?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";
+    }
+}
index e05508d5e31803626fe4aa148c674cd4676ebc8e..8e1539702bd23e29b67b63c9bbd4ca01a2c38287 100644 (file)
@@ -72,7 +72,9 @@ class CleanupImages extends Command
 
     protected function showDeletedImages($paths)
     {
-        if ($this->getOutput()->getVerbosity() <= OutputInterface::VERBOSITY_NORMAL) return;
+        if ($this->getOutput()->getVerbosity() <= OutputInterface::VERBOSITY_NORMAL) {
+            return;
+        }
         if (count($paths) > 0) {
             $this->line('Images to delete:');
         }
index 5d4449f2bd7817e38db5eb0284baa781c7e0858e..fb1c6d48b59e4c714198bebbbea12c269468a636 100644 (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;
diff --git a/app/Exceptions/SocialSignInAccountNotUsed.php b/app/Exceptions/SocialSignInAccountNotUsed.php
new file mode 100644 (file)
index 0000000..7eaa72b
--- /dev/null
@@ -0,0 +1,6 @@
+<?php namespace BookStack\Exceptions;
+
+class SocialSignInAccountNotUsed extends SocialSignInException
+{
+
+}
index 54e14bfb6f4cd654c7bf7e9c2353432b373c6e6f..74644aa2fd848f0cde64171d3c1e2bd31f2c6c11 100644 (file)
@@ -201,10 +201,7 @@ class AttachmentController extends Controller
         }
 
         $attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
-        return response($attachmentContents, 200, [
-            'Content-Type' => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="'. $attachment->getFileName() .'"'
-        ]);
+        return $this->downloadResponse($attachmentContents, $attachment->getFileName());
     }
 
     /**
index 1bbd5e2ba5e2b6bcd8d9527742978d70a20e3188..385994324eae71e78e0be1c916bc588f678dce7b 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
-use BookStack\Exceptions\ConfirmationEmailException;
+use BookStack\Exceptions\SocialSignInAccountNotUsed;
 use BookStack\Exceptions\SocialSignInException;
 use BookStack\Exceptions\UserRegistrationException;
 use BookStack\Repos\UserRepo;
@@ -16,6 +16,7 @@ use Illuminate\Http\Response;
 use Validator;
 use BookStack\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\RegistersUsers;
+use Laravel\Socialite\Contracts\User as SocialUser;
 
 class RegisterController extends Controller
 {
@@ -133,25 +134,28 @@ class RegisterController extends Controller
      * The registrations flow for all users.
      * @param array $userData
      * @param bool|false|SocialAccount $socialAccount
+     * @param bool $emailVerified
      * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
      * @throws UserRegistrationException
      */
-    protected function registerUser(array $userData, $socialAccount = false)
+    protected function registerUser(array $userData, $socialAccount = false, $emailVerified = false)
     {
-        if (setting('registration-restrict')) {
-            $restrictedEmailDomains = explode(',', str_replace(' ', '', setting('registration-restrict')));
+        $registrationRestrict = setting('registration-restrict');
+
+        if ($registrationRestrict) {
+            $restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
             $userEmailDomain = $domain = substr(strrchr($userData['email'], "@"), 1);
             if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
                 throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), '/register');
             }
         }
 
-        $newUser = $this->userRepo->registerNew($userData);
+        $newUser = $this->userRepo->registerNew($userData, $emailVerified);
         if ($socialAccount) {
             $newUser->socialAccounts()->save($socialAccount);
         }
 
-        if (setting('registration-confirmation') || setting('registration-restrict')) {
+        if ((setting('registration-confirmation') || $registrationRestrict) && !$emailVerified) {
             $newUser->save();
 
             try {
@@ -250,7 +254,6 @@ class RegisterController extends Controller
      * @throws SocialSignInException
      * @throws UserRegistrationException
      * @throws \BookStack\Exceptions\SocialDriverNotConfigured
-     * @throws ConfirmationEmailException
      */
     public function socialCallback($socialDriver, Request $request)
     {
@@ -267,12 +270,24 @@ class RegisterController extends Controller
         }
 
         $action = session()->pull('social-callback');
+
+        // Attempt login or fall-back to register if allowed.
+        $socialUser = $this->socialAuthService->getSocialUser($socialDriver);
         if ($action == 'login') {
-            return $this->socialAuthService->handleLoginCallback($socialDriver);
+            try {
+                return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
+            } catch (SocialSignInAccountNotUsed $exception) {
+                if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) {
+                    return $this->socialRegisterCallback($socialDriver, $socialUser);
+                }
+                throw $exception;
+            }
         }
+
         if ($action == 'register') {
-            return $this->socialRegisterCallback($socialDriver);
+            return $this->socialRegisterCallback($socialDriver, $socialUser);
         }
+
         return redirect()->back();
     }
 
@@ -288,15 +303,16 @@ class RegisterController extends Controller
 
     /**
      * Register a new user after a registration callback.
-     * @param $socialDriver
+     * @param string $socialDriver
+     * @param SocialUser $socialUser
      * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
      * @throws UserRegistrationException
-     * @throws \BookStack\Exceptions\SocialDriverNotConfigured
      */
-    protected function socialRegisterCallback($socialDriver)
+    protected function socialRegisterCallback(string $socialDriver, SocialUser $socialUser)
     {
-        $socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver);
+        $socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
         $socialAccount = $this->socialAuthService->fillSocialAccount($socialDriver, $socialUser);
+        $emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver);
 
         // Create an array of the user data to create a new user instance
         $userData = [
@@ -304,6 +320,6 @@ class RegisterController extends Controller
             'email' => $socialUser->getEmail(),
             'password' => str_random(30)
         ];
-        return $this->registerUser($userData, $socialAccount);
+        return $this->registerUser($userData, $socialAccount, $emailVerified);
     }
 }
index 2c3946239cac3981e4abffab0fe5ae14b4ce2de9..ea39a771ec048529bab8995f2967306613132108 100644 (file)
@@ -299,10 +299,7 @@ class BookController extends Controller
     {
         $book = $this->entityRepo->getBySlug('book', $bookSlug);
         $pdfContent = $this->exportService->bookToPdf($book);
-        return response()->make($pdfContent, 200, [
-            'Content-Type'        => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="' . $bookSlug . '.pdf'
-        ]);
+        return $this->downloadResponse($pdfContent, $bookSlug . '.pdf');
     }
 
     /**
@@ -314,10 +311,7 @@ class BookController extends Controller
     {
         $book = $this->entityRepo->getBySlug('book', $bookSlug);
         $htmlContent = $this->exportService->bookToContainedHtml($book);
-        return response()->make($htmlContent, 200, [
-            'Content-Type'        => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="' . $bookSlug . '.html'
-        ]);
+        return $this->downloadResponse($htmlContent, $bookSlug . '.html');
     }
 
     /**
@@ -328,10 +322,7 @@ class BookController extends Controller
     public function exportPlainText($bookSlug)
     {
         $book = $this->entityRepo->getBySlug('book', $bookSlug);
-        $htmlContent = $this->exportService->bookToPlainText($book);
-        return response()->make($htmlContent, 200, [
-            'Content-Type'        => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="' . $bookSlug . '.txt'
-        ]);
+        $textContent = $this->exportService->bookToPlainText($book);
+        return $this->downloadResponse($textContent, $bookSlug . '.txt');
     }
 }
diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php
new file mode 100644 (file)
index 0000000..8c7f781
--- /dev/null
@@ -0,0 +1,243 @@
+<?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());
+    }
+}
index b737afc6df21e5ff8697dd0b4fc681c2d7d71a23..1fe231a6597e909198f36fb0d4d1588a7a911836 100644 (file)
@@ -250,10 +250,7 @@ class ChapterController extends Controller
     {
         $chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
         $pdfContent = $this->exportService->chapterToPdf($chapter);
-        return response()->make($pdfContent, 200, [
-            'Content-Type'        => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="' . $chapterSlug . '.pdf'
-        ]);
+        return $this->downloadResponse($pdfContent, $chapterSlug . '.pdf');
     }
 
     /**
@@ -266,10 +263,7 @@ class ChapterController extends Controller
     {
         $chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
         $containedHtml = $this->exportService->chapterToContainedHtml($chapter);
-        return response()->make($containedHtml, 200, [
-            'Content-Type'        => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="' . $chapterSlug . '.html'
-        ]);
+        return $this->downloadResponse($containedHtml, $chapterSlug . '.html');
     }
 
     /**
@@ -281,10 +275,7 @@ class ChapterController extends Controller
     public function exportPlainText($bookSlug, $chapterSlug)
     {
         $chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
-        $containedHtml = $this->exportService->chapterToPlainText($chapter);
-        return response()->make($containedHtml, 200, [
-            'Content-Type'        => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="' . $chapterSlug . '.txt'
-        ]);
+        $chapterText = $this->exportService->chapterToPlainText($chapter);
+        return $this->downloadResponse($chapterText, $chapterSlug . '.txt');
     }
 }
index a51ff5d77f9cc305575677f8557fc52056dfd862..33b57b7d9a56b14f9f406c8566346e01767e0077 100644 (file)
@@ -136,7 +136,6 @@ abstract class Controller extends BaseController
 
     /**
      * Create the response for when a request fails validation.
-     *
      * @param  \Illuminate\Http\Request  $request
      * @param  array  $errors
      * @return \Symfony\Component\HttpFoundation\Response
@@ -151,4 +150,18 @@ abstract class Controller extends BaseController
             ->withInput($request->input())
             ->withErrors($errors, $this->errorBag());
     }
+
+    /**
+     * Create a response that forces a download in the browser.
+     * @param string $content
+     * @param string $fileName
+     * @return \Illuminate\Http\Response
+     */
+    protected function downloadResponse(string $content, string $fileName)
+    {
+        return response()->make($content, 200, [
+            'Content-Type'        => 'application/octet-stream',
+            'Content-Disposition' => 'attachment; filename="' . $fileName . '"'
+        ]);
+    }
 }
index 2077f6888fbc84e9a57c42836c0958f1d844213a..e472503187a61db9f197ab6d2b4323a22009fe31 100644 (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);
 
+        $homepageOptions = ['default', 'books', 'bookshelves', 'page'];
+        $homepageOption = setting('app-homepage-type', 'default');
+        if (!in_array($homepageOption, $homepageOptions)) {
+            $homepageOption = 'default';
+        }
 
-        $customHomepage = false;
-        $books = false;
-        $booksViewType = false;
+        $commonData = [
+            'activity' => $activity,
+            'recents' => $recents,
+            'recentlyUpdatedPages' => $recentlyUpdatedPages,
+            'draftPages' => $draftPages,
+        ];
+
+        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);
+        }
 
-        // Check book homepage
-        $bookHomepageSetting = setting('app-book-homepage');
-        if ($bookHomepageSetting) {
+        if ($homepageOption === 'books') {
             $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);
-            }
+            $data = array_merge($commonData, ['books' => $books, 'booksViewType' => $booksViewType]);
+            return view('common.home-book', $data);
         }
 
-        $view = 'home';
-        if ($bookHomepageSetting) {
-            $view = 'home-book';
-        } else if ($customHomepage) {
-            $view = 'home-custom';
+        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/' . $view, [
-            'activity' => $activity,
-            'recents' => $recents,
-            'recentlyUpdatedPages' => $recentlyUpdatedPages,
-            'draftPages' => $draftPages,
-            'customHomepage' => $customHomepage,
-            'books' => $books,
-            'booksViewType' => $booksViewType
-        ]);
+        return view('common.home', $commonData);
     }
 
     /**
index 25a0503ebeab00245d69a85389ddd28b5b7db804..80bbe56c1afe179935e08abaa73cba137243b388 100644 (file)
@@ -454,6 +454,40 @@ class PageController extends Controller
         return redirect($page->getUrl());
     }
 
+
+    /**
+     * Deletes a revision using the id of the specified revision.
+     * @param string $bookSlug
+     * @param string $pageSlug
+     * @param int $revId
+     * @throws NotFoundException
+     * @throws BadRequestException
+     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
+     */
+    public function destroyRevision($bookSlug, $pageSlug, $revId)
+    {
+        $page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
+        $this->checkOwnablePermission('page-delete', $page);
+
+        $revision = $page->revisions()->where('id', '=', $revId)->first();
+        if ($revision === null) {
+            throw new NotFoundException("Revision #{$revId} not found");
+        }
+
+        // Get the current revision for the page
+        $currentRevision = $page->getCurrentRevision();
+
+        // Check if its the latest revision, cannot delete latest revision.
+        if (intval($currentRevision->id) === intval($revId)) {
+            session()->flash('error', trans('entities.revision_cannot_delete_latest'));
+            return response()->view('pages/revisions', ['page' => $page, 'book' => $page->book, 'current' => $page], 400);
+        }
+
+        $revision->delete();
+        session()->flash('success', trans('entities.revision_delete_success'));
+        return view('pages/revisions', ['page' => $page, 'book' => $page->book, 'current' => $page]);
+    }
+
     /**
      * Exports a page to a PDF.
      * https://github.com/barryvdh/laravel-dompdf
@@ -466,10 +500,7 @@ class PageController extends Controller
         $page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
         $page->html = $this->entityRepo->renderPage($page);
         $pdfContent = $this->exportService->pageToPdf($page);
-        return response()->make($pdfContent, 200, [
-            'Content-Type'        => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="' . $pageSlug . '.pdf'
-        ]);
+        return $this->downloadResponse($pdfContent, $pageSlug . '.pdf');
     }
 
     /**
@@ -483,10 +514,7 @@ class PageController extends Controller
         $page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
         $page->html = $this->entityRepo->renderPage($page);
         $containedHtml = $this->exportService->pageToContainedHtml($page);
-        return response()->make($containedHtml, 200, [
-            'Content-Type'        => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="' . $pageSlug . '.html'
-        ]);
+        return $this->downloadResponse($containedHtml, $pageSlug . '.html');
     }
 
     /**
@@ -498,11 +526,8 @@ class PageController extends Controller
     public function exportPlainText($bookSlug, $pageSlug)
     {
         $page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug);
-        $containedHtml = $this->exportService->pageToPlainText($page);
-        return response()->make($containedHtml, 200, [
-            'Content-Type'        => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="' . $pageSlug . '.txt'
-        ]);
+        $pageText = $this->exportService->pageToPlainText($page);
+        return $this->downloadResponse($pageText, $pageSlug . '.txt');
     }
 
     /**
index d50baa86f484b59bc14fd491c457d1c86209fd62..f6bd13e6f7de8bd15de6505821460355ade582a4 100644 (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");
+    }
 }
index bdbf6ccbd9c57832cb29c560770900683e5c7938..528ff40478dbeb92f436ee747b0cb9f07b9071d8 100644 (file)
@@ -6,6 +6,9 @@ use Illuminate\Http\Request;
 
 class Localization
 {
+
+    protected $rtlLocales = ['ar'];
+
     /**
      * Handle an incoming request.
      *
@@ -23,6 +26,11 @@ class Localization
             $locale = setting()->getUser(user(), 'language', $defaultLang);
         }
 
+        // Set text direction
+        if (in_array($locale, $this->rtlLocales)) {
+            config()->set('app.rtl', true);
+        }
+
         app()->setLocale($locale);
         Carbon::setLocale($locale);
         return $next($request);
index 412beea9071aa4d5e314909990e46181c7049f48..acc82df90a1ec7fcb572dcf3c636499c9ba5b94e 100644 (file)
@@ -19,5 +19,4 @@ class Image extends Ownable
     {
         return Images::getThumbnail($this, $width, $height, $keepRatio);
     }
-
 }
index 9554504b35d2466e4c8741ce41bca05adaa8e966..5c03e7d66f610d21b8499370307314b924bcca3d 100644 (file)
@@ -112,4 +112,13 @@ class Page extends Entity
         $htmlQuery = $withContent ? 'html' : "'' as html";
         return "'BookStack\\\\Page' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, {$htmlQuery}, book_id, priority, chapter_id, draft, created_by, updated_by, updated_at, created_at";
     }
+
+    /**
+     * Get the current revision for the page if existing
+     * @return \BookStack\PageRevision|null
+     */
+    public function getCurrentRevision()
+    {
+        return $this->revisions()->first();
+    }
 }
index bdd1e37b10ddfd58c699f6d345a2e53acb58850c..1167ea7daa849c5b34ee8b5bd61da6b8eb722c2d 100644 (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,48 @@ 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;
+    }
 }
index 6f7ea1dc8f4284b950aa63cf2c0f2cf85ddd15eb..68c9270bec9ab54af7194ba9f9b58cb532f974a5 100644 (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);
index d113b676ab5a3616a73232a91dae7f1f2734cbc6..6fe8d2619dd2f31001cb791dfe26e89a84c25a33 100644 (file)
@@ -76,14 +76,15 @@ class UserRepo
         return $query->paginate($count);
     }
 
-    /**
+     /**
      * Creates a new user and attaches a role to them.
      * @param array $data
+     * @param boolean $verifyEmail
      * @return User
      */
-    public function registerNew(array $data)
+    public function registerNew(array $data, $verifyEmail = false)
     {
-        $user = $this->create($data);
+        $user = $this->create($data, $verifyEmail);
         $this->attachDefaultRole($user);
 
         // Get avatar from gravatar and save
@@ -141,15 +142,17 @@ class UserRepo
     /**
      * Create a new basic instance of user.
      * @param array $data
+     * @param boolean $verifyEmail
      * @return User
      */
-    public function create(array $data)
+    public function create(array $data, $verifyEmail = false)
     {
+
         return $this->user->forceCreate([
             'name'     => $data['name'],
             'email'    => $data['email'],
             'password' => bcrypt($data['password']),
-            'email_confirmed' => false
+            'email_confirmed' => $verifyEmail
         ]);
     }
 
index 73a677ac23ecf144634b3447aff95c83b45c1fd8..7b73c457c08ec76e5d490e1971b7bb1e230b8189 100644 (file)
@@ -316,25 +316,25 @@ class ImageService extends UploadService
         $deletedPaths = [];
 
         $this->image->newQuery()->whereIn('type', $types)
-            ->chunk(1000, function($images) use ($types, $checkRevisions, &$deletedPaths, $dryRun) {
-             foreach ($images as $image) {
-                 $searchQuery = '%' . basename($image->path) . '%';
-                 $inPage = DB::table('pages')
+            ->chunk(1000, function ($images) use ($types, $checkRevisions, &$deletedPaths, $dryRun) {
+                foreach ($images as $image) {
+                    $searchQuery = '%' . basename($image->path) . '%';
+                    $inPage = DB::table('pages')
                          ->where('html', 'like', $searchQuery)->count() > 0;
-                 $inRevision = false;
-                 if ($checkRevisions) {
-                     $inRevision =  DB::table('page_revisions')
+                    $inRevision = false;
+                    if ($checkRevisions) {
+                        $inRevision =  DB::table('page_revisions')
                              ->where('html', 'like', $searchQuery)->count() > 0;
-                 }
-
-                 if (!$inPage && !$inRevision) {
-                     $deletedPaths[] = $image->path;
-                     if (!$dryRun) {
-                         $this->destroy($image);
-                     }
-                 }
-             }
-        });
+                    }
+
+                    if (!$inPage && !$inRevision) {
+                        $deletedPaths[] = $image->path;
+                        if (!$dryRun) {
+                            $this->destroy($image);
+                        }
+                    }
+                }
+            });
         return $deletedPaths;
     }
 
index 11223433bd113041a8edd3138d92dbd6445ef867..16cee9f7dea1c08e7a5c258a7a2a6812143bc2b0 100644 (file)
@@ -330,14 +330,14 @@ class LdapService
             $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
         }
 
-        $roles = Role::query()->where(function(Builder $query) use ($groupNames) {
+        $roles = Role::query()->where(function (Builder $query) use ($groupNames) {
             $query->whereIn('name', $groupNames);
             foreach ($groupNames as $groupName) {
                 $query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
             }
         })->get();
 
-        $matchedRoles = $roles->filter(function(Role $role) use ($groupNames) {
+        $matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
             return $this->roleMatchesGroupNames($role, $groupNames);
         });
 
@@ -366,5 +366,4 @@ class LdapService
         $roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
         return in_array($roleName, $groupNames);
     }
-
 }
index 0dd316b34a06e2c2fba24b36fd09befbb74a0bd2..045824517e3542b21501ad41d465fcb0800c91a3 100644 (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,31 @@ 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 +170,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 +191,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 +235,7 @@ class PermissionService
     /**
      * Rebuild the entity jointPermissions for a particular entity.
      * @param Entity $entity
+     * @throws \Throwable
      */
     public function buildJointPermissionsForEntity(Entity $entity)
     {
@@ -214,7 +246,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 +260,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 +288,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 +452,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 +524,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 +616,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 +653,6 @@ class PermissionService
             });
         }
 
-        if ($this->isAdmin()) {
-            $this->clean();
-            return $query;
-        }
-
         $this->currentAction = $action;
         return $this->entityRestrictionQuery($query);
     }
@@ -639,10 +668,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 +700,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 +724,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
index dac6b7773690b42d4576dd77272dce3f28de1d74..9017dfebe7c4f1f0f39364ee5ec3e0c6e6d10675 100644 (file)
@@ -1,13 +1,12 @@
 <?php namespace BookStack\Services;
 
-use BookStack\Http\Requests\Request;
-use GuzzleHttp\Exception\ClientException;
+use BookStack\Exceptions\SocialSignInAccountNotUsed;
 use Laravel\Socialite\Contracts\Factory as Socialite;
 use BookStack\Exceptions\SocialDriverNotConfigured;
-use BookStack\Exceptions\SocialSignInException;
 use BookStack\Exceptions\UserRegistrationException;
 use BookStack\Repos\UserRepo;
 use BookStack\SocialAccount;
+use Laravel\Socialite\Contracts\User as SocialUser;
 
 class SocialAuthService
 {
@@ -58,18 +57,13 @@ class SocialAuthService
 
     /**
      * Handle the social registration process on callback.
-     * @param $socialDriver
-     * @return \Laravel\Socialite\Contracts\User
-     * @throws SocialDriverNotConfigured
+     * @param string $socialDriver
+     * @param SocialUser $socialUser
+     * @return SocialUser
      * @throws UserRegistrationException
      */
-    public function handleRegistrationCallback($socialDriver)
+    public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser)
     {
-        $driver = $this->validateDriver($socialDriver);
-
-        // Get user details from social driver
-        $socialUser = $this->socialite->driver($driver)->user();
-
         // Check social account has not already been used
         if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) {
             throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver]), '/login');
@@ -83,18 +77,27 @@ class SocialAuthService
         return $socialUser;
     }
 
+    /**
+     * Get the social user details via the social driver.
+     * @param string $socialDriver
+     * @return SocialUser
+     * @throws SocialDriverNotConfigured
+     */
+    public function getSocialUser(string $socialDriver)
+    {
+        $driver = $this->validateDriver($socialDriver);
+        return $this->socialite->driver($driver)->user();
+    }
+
     /**
      * Handle the login process on a oAuth callback.
      * @param $socialDriver
+     * @param SocialUser $socialUser
      * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
-     * @throws SocialDriverNotConfigured
-     * @throws SocialSignInException
+     * @throws SocialSignInAccountNotUsed
      */
-    public function handleLoginCallback($socialDriver)
+    public function handleLoginCallback($socialDriver, SocialUser $socialUser)
     {
-        $driver = $this->validateDriver($socialDriver);
-        // Get user details from social driver
-        $socialUser = $this->socialite->driver($driver)->user();
         $socialId = $socialUser->getId();
 
         // Get any attached social accounts or users
@@ -136,7 +139,7 @@ class SocialAuthService
             $message .= trans('errors.social_account_register_instructions', ['socialAccount' => title_case($socialDriver)]);
         }
         
-        throw new SocialSignInException($message, '/login');
+        throw new SocialSignInAccountNotUsed($message, '/login');
     }
 
     /**
@@ -199,8 +202,28 @@ class SocialAuthService
     }
 
     /**
-     * @param string                            $socialDriver
-     * @param \Laravel\Socialite\Contracts\User $socialUser
+     * Check if the current config for the given driver allows auto-registration.
+     * @param string $driver
+     * @return bool
+     */
+    public function driverAutoRegisterEnabled(string $driver)
+    {
+        return config('services.' . strtolower($driver) . '.auto_register') === true;
+    }
+
+    /**
+     * Check if the current config for the given driver allow email address auto-confirmation.
+     * @param string $driver
+     * @return bool
+     */
+    public function driverAutoConfirmEmailEnabled(string $driver)
+    {
+        return config('services.' . strtolower($driver) . '.auto_confirm') === true;
+    }
+
+    /**
+     * @param string $socialDriver
+     * @param SocialUser $socialUser
      * @return SocialAccount
      */
     public function fillSocialAccount($socialDriver, $socialUser)
index b0883b9bea6922780b062f7e7c6d114238909b0f..21ac6441b625ded577e03bcb83d1b9328428cc9a 100755 (executable)
@@ -77,8 +77,20 @@ return [
     */
 
     'locale' => env('APP_LANG', 'en'),
+    'locales' => ['en', 'ar', 'de', 'es', 'es_AR', 'fr', 'nl', 'pt_BR', 'sk', 'sv', 'ja', 'pl', 'it', 'ru', 'zh_CN', 'zh_TW'],
 
-    'locales' => ['en', 'de', 'es', 'es_AR', 'fr', 'nl', 'pt_BR', 'sk', 'sv', 'ja', 'pl', 'it', 'ru', 'zh_CN', 'zh_TW'],
+    /*
+    |--------------------------------------------------------------------------
+    | Right-to-left text control
+    |--------------------------------------------------------------------------
+    |
+    | Right-to-left text control is set to false by default since English
+    | is the primary supported application but this may be dynamically
+    | altered by the applications localization system.
+    |
+    */
+
+    'rtl' => false,
 
     /*
     |--------------------------------------------------------------------------
index fab0c1d75ebb7f9df9a54df1a6ce26b81a233f4a..2b0f260cde6b40b8653947626fc19ff37e0a2658 100644 (file)
@@ -48,6 +48,8 @@ return [
         'client_secret' => env('GITHUB_APP_SECRET', false),
         'redirect'      => env('APP_URL') . '/login/service/github/callback',
         'name'          => 'GitHub',
+        'auto_register' => env('GITHUB_AUTO_REGISTER', false),
+        'auto_confirm' => env('GITHUB_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'google'   => [
@@ -55,6 +57,8 @@ return [
         'client_secret' => env('GOOGLE_APP_SECRET', false),
         'redirect'      => env('APP_URL') . '/login/service/google/callback',
         'name'          => 'Google',
+        'auto_register' => env('GOOGLE_AUTO_REGISTER', false),
+        'auto_confirm' => env('GOOGLE_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'slack'   => [
@@ -62,6 +66,8 @@ return [
         'client_secret' => env('SLACK_APP_SECRET', false),
         'redirect'      => env('APP_URL') . '/login/service/slack/callback',
         'name'          => 'Slack',
+        'auto_register' => env('SLACK_AUTO_REGISTER', false),
+        'auto_confirm' => env('SLACK_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'facebook'   => [
@@ -69,6 +75,8 @@ return [
         'client_secret' => env('FACEBOOK_APP_SECRET', false),
         'redirect'      => env('APP_URL') . '/login/service/facebook/callback',
         'name'          => 'Facebook',
+        'auto_register' => env('FACEBOOK_AUTO_REGISTER', false),
+        'auto_confirm' => env('FACEBOOK_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'twitter'   => [
@@ -76,6 +84,8 @@ return [
         'client_secret' => env('TWITTER_APP_SECRET', false),
         'redirect'      => env('APP_URL') . '/login/service/twitter/callback',
         'name'          => 'Twitter',
+        'auto_register' => env('TWITTER_AUTO_REGISTER', false),
+        'auto_confirm' => env('TWITTER_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'azure'   => [
@@ -84,6 +94,8 @@ return [
         'tenant'       => env('AZURE_TENANT', false),
         'redirect'      => env('APP_URL') . '/login/service/azure/callback',
         'name'          => 'Microsoft Azure',
+        'auto_register' => env('AZURE_AUTO_REGISTER', false),
+        'auto_confirm' => env('AZURE_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'okta' => [
@@ -92,6 +104,8 @@ return [
         'redirect' => env('APP_URL') . '/login/service/okta/callback', 
         'base_url' => env('OKTA_BASE_URL'), 
         'name'          => 'Okta',
+        'auto_register' => env('OKTA_AUTO_REGISTER', false),
+        'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'gitlab' => [
@@ -100,6 +114,8 @@ return [
         'redirect'      => env('APP_URL') . '/login/service/gitlab/callback',
         'instance_uri'  => env('GITLAB_BASE_URI'), // Needed only for self hosted instances
         'name'          => 'GitLab',
+        'auto_register' => env('GITLAB_AUTO_REGISTER', false),
+        'auto_confirm' => env('GITLAB_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'twitch' => [
@@ -107,12 +123,17 @@ return [
         'client_secret' => env('TWITCH_APP_SECRET'),
         'redirect' => env('APP_URL') . '/login/service/twitch/callback',
         'name'          => 'Twitch',
+        'auto_register' => env('TWITCH_AUTO_REGISTER', false),
+        'auto_confirm' => env('TWITCH_AUTO_CONFIRM_EMAIL', false),
     ],
+
     'discord' => [
         'client_id' => env('DISCORD_APP_ID'),
         'client_secret' => env('DISCORD_APP_SECRET'),
         'redirect' => env('APP_URL') . '/login/service/discord/callback',
         'name' => 'Discord',
+        'auto_register' => env('DISCORD_AUTO_REGISTER', false),
+        'auto_confirm' => env('DISCORD_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'ldap' => [
index b334ffb3c255a5431e8694aa810105a980d8fe47..328fed27d7121f2a12b2dbb18f1ccd5cefac6691 100644 (file)
@@ -109,7 +109,7 @@ return [
     |
     */
 
-    'cookie' => 'laravel_session',
+    'cookie' => env('SESSION_COOKIE_NAME', 'bookstack_session'),
 
     /*
     |--------------------------------------------------------------------------
index c68f5c1e15ac8e4ce5ef86dc6e17be38a8a33928..3d6ed1d633301d36eda41636bf0d7eac563be8bc 100644 (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,
index 4c1b43c4e77a5cf60df0fa73be786c58518652c5..ce11f7b88efb19174f5cfa23783f62afc3a68244 100644 (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();
     }
 
     /**
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 (file)
index 0000000..e92b0ed
--- /dev/null
@@ -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();
+    }
+}
index 41ac6650d9d80fc5a94461329a84eb8000af14cf..dcf5893529435e219cde412e415d7c7e25f9dbb1 100644 (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();
     }
index ec4da5ce228ddb90507853f54849bf59ea3c7736..f8c43993b73a55d568ae8e070c5c41c39b7ac636 100644 (file)
       "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",
index 12d972cf9b7a75bc49f51ff05b35bf7f724cc3cb..58f2dad5e5566cc222261a646bcd9db61a45580c 100644 (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",
index 9434b710f8eead7d242c9038705f9c2dbbbb9925..4c1e4f66c9fd5e8b30365d9b3734a0ca1ce785c0 100644 (file)
         <env name="STORAGE_TYPE" value="local"/>
         <env name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/>
         <env name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/>
+        <env name="GITHUB_AUTO_REGISTER" value=""/>
+        <env name="GITHUB_AUTO_CONFIRM_EMAIL" value=""/>
         <env name="GOOGLE_APP_ID" value="aaaaaaaaaaaaaa"/>
         <env name="GOOGLE_APP_SECRET" value="aaaaaaaaaaaaaa"/>
+        <env name="GOOGLE_AUTO_REGISTER" value=""/>
+        <env name="GOOGLE_AUTO_CONFIRM_EMAIL" value=""/>
         <env name="APP_URL" value="http://bookstack.dev"/>
     </php>
 </phpunit>
diff --git a/resources/assets/icons/bookshelf.svg b/resources/assets/icons/bookshelf.svg
new file mode 100644 (file)
index 0000000..03da68f
--- /dev/null
@@ -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 (file)
index 0000000..e1f66a5
--- /dev/null
@@ -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
index aa69f326562fb0831a069bd17fcf1d22e9d07f71..768e0983f12b385d4e1b722828f791028cce2bfc 100644 (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 = {};
index 9e2bb3915e4dc3a985b8a2e88da58cc7aa047847..a555376e8eaca5094620b3c3fe03a68824c8acca 100644 (file)
@@ -8,6 +8,7 @@ class MarkdownEditor {
 
     constructor(elem) {
         this.elem = elem;
+        this.textDirection = document.getElementById('page-editor').getAttribute('text-direction');
         this.markdown = new MarkdownIt({html: true});
         this.markdown.use(mdTasksLists, {label: true});
 
@@ -98,6 +99,9 @@ class MarkdownEditor {
 
     codeMirrorSetup() {
         let cm = this.cm;
+        // Text direction
+        // cm.setOption('direction', this.textDirection);
+        cm.setOption('direction', 'ltr'); // Will force to remain as ltr for now due to issues when HTML is in editor.
         // Custom key commands
         let metaKey = code.getMetaKey();
         const extraKeys = {};
index e697d5f687f4d5f9f97a5d3a7c89be323626a001..5fd2920f43aecf7ccb85bb4017bc11af90535243 100644 (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;
diff --git a/resources/assets/js/components/shelf-sort.js b/resources/assets/js/components/shelf-sort.js
new file mode 100644 (file)
index 0000000..59ac712
--- /dev/null
@@ -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
index a8a2ff175ed0c732b38cbe06be3fa394031c4694..04886563024f59be5f0777bbd1fbdcc407e146fa 100644 (file)
@@ -370,6 +370,7 @@ class WysiwygEditor {
 
     constructor(elem) {
         this.elem = elem;
+        this.textDirection = document.getElementById('page-editor').getAttribute('text-direction');
 
         this.plugins = "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor media";
         this.loadPlugins();
@@ -385,6 +386,14 @@ class WysiwygEditor {
             drawIoPlugin();
             this.plugins += ' drawio';
         }
+        if (this.textDirection === 'rtl') {
+            this.plugins += ' directionality'
+        }
+    }
+
+    getToolBar() {
+        const textDirPlugins = this.textDirection === 'rtl' ? 'ltr rtl' : '';
+        return `undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr drawio media | removeformat code ${textDirPlugins} fullscreen`
     }
 
     getTinyMceConfig() {
@@ -397,6 +406,7 @@ class WysiwygEditor {
             body_class: 'page-content',
             browser_spellcheck: true,
             relative_urls: false,
+            directionality : this.textDirection,
             remove_script_host: false,
             document_base_url: window.baseUrl('/'),
             statusbar: false,
@@ -407,7 +417,7 @@ class WysiwygEditor {
             valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre],+div[img]",
             plugins: this.plugins,
             imagetools_toolbar: 'imageoptions',
-            toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr drawio media | removeformat code fullscreen",
+            toolbar: this.getToolBar(),
             content_style: "body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}",
             style_formats: [
                 {title: "Header Large", format: "h2"},
index ae63a8ce869a766f66abdf8d9357936351c27006..de863e09fee8ae692895e6f34a0990d161b76285 100644 (file)
@@ -157,6 +157,7 @@ function wysiwygView(elem) {
 
     newWrap.className = 'CodeMirrorContainer';
     newWrap.setAttribute('data-lang', lang);
+    newWrap.setAttribute('dir', 'ltr');
     newTextArea.style.display = 'none';
     elem.parentNode.replaceChild(newWrap, elem);
 
index 8f15153b5b6c0c2eba56ba7773c3c5ba0c8e5ec2..0e1f85ce62cd1e801d006814b4e47ddaabab49d5 100644 (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;
index 3338b39383f4dd99ffbecc57bf910fe0aa039075..0bf6be4c358e678fd3be5415ae397f4d9fed0204 100644 (file)
@@ -367,7 +367,7 @@ ul.pagination {
     padding: $-xs $-m;
     line-height: 1.2;
   }
-  a {
+  a, button {
     display: block;
     padding: $-xs $-m;
     color: #555;
@@ -382,6 +382,10 @@ ul.pagination {
       width: 16px;
     }
   }
+  button {
+    width: 100%;
+    text-align: left;
+  }
   li.border-bottom {
     border-bottom: 1px solid #DDD;
   }
@@ -408,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
index 38b044268b7c587311067eb718987d4d4fc24e60..ec24e2fa64f51be5be1cda6eab84eca656651244 100644 (file)
@@ -41,6 +41,9 @@ table.table {
   .text-center {
     text-align: center;
   }
+  td.actions {
+    overflow: visible;
+  }
 }
 
 table.no-style {
index da11846d8820a563e7983276bb8ad7f3f8794ae0..0063c4672cca2c9d257c28e57c449b483c74f499 100644 (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;
@@ -343,6 +351,7 @@ ul, ol {
 }
 ul {
   padding-left: $-m * 1.3;
+  padding-right: $-m * 1.3;
   list-style: disc;
   ul {
     list-style: circle;
@@ -357,6 +366,7 @@ ul {
 ol {
   list-style: decimal;
   padding-left: $-m * 2;
+  padding-right: $-m * 2;
 }
 
 li.checkbox-item, li.task-list-item {
index e62d37efebb3c1c6463c64b642d7c83d859dd093..006d1b3f08d105b40a54f2dc0f8ee2d11501be67 100644 (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;
index 0b2dfbf75942ab95984315cc32a7f499bab4c60c..ab5972cbdba68ebacbc1cdd5610821ebfd05012c 100644 (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;
diff --git a/resources/lang/ar/activities.php b/resources/lang/ar/activities.php
new file mode 100644 (file)
index 0000000..fd13b16
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+return [
+
+    /**
+     * Activity text strings.
+     * Is used for all the text within activity logs & notifications.
+     */
+
+    // Pages
+    'page_create'                 => 'تم إنشاء صفحة',
+    'page_create_notification'    => 'تم إنشاء الصفحة بنجاح',
+    'page_update'                 => 'تم تحديث الصفحة',
+    'page_update_notification'    => 'تم تحديث الصفحة بنجاح',
+    'page_delete'                 => 'تم حذف الصفحة',
+    'page_delete_notification'    => 'تم حذف الصفحة بنجاح',
+    'page_restore'                => 'تمت استعادة الصفحة',
+    'page_restore_notification'   => 'تمت استعادة الصفحة بنجاح',
+    'page_move'                   => 'تم نقل الصفحة',
+
+    // Chapters
+    'chapter_create'              => 'تم إنشاء فصل',
+    'chapter_create_notification' => 'تم إنشاء فصل بنجاح',
+    'chapter_update'              => 'تم تحديث الفصل',
+    'chapter_update_notification' => 'تم تحديث الفصل بنجاح',
+    'chapter_delete'              => 'تم حذف الفصل',
+    'chapter_delete_notification' => 'تم حذف الفصل بنجاح',
+    'chapter_move'                => 'تم نقل الفصل',
+
+    // Books
+    'book_create'                 => 'تم إنشاء كتاب',
+    'book_create_notification'    => 'تم إنشاء كتاب بنجاح',
+    'book_update'                 => 'تم تحديث الكتاب',
+    'book_update_notification'    => 'تم تحديث الكتاب بنجاح',
+    'book_delete'                 => 'تم حذف الكتاب',
+    'book_delete_notification'    => 'تم حذف الكتاب بنجاح',
+    'book_sort'                   => 'تم سرد الكتاب',
+    'book_sort_notification'      => 'تمت إعادة سرد الكتاب بنجاح',
+
+    // Other
+    'commented_on'                => 'تم التعليق',
+];
diff --git a/resources/lang/ar/auth.php b/resources/lang/ar/auth.php
new file mode 100644 (file)
index 0000000..bad0910
--- /dev/null
@@ -0,0 +1,76 @@
+<?php
+return [
+    /*
+    |--------------------------------------------------------------------------
+    | Authentication Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are used during authentication for various
+    | messages that we need to display to the user. You are free to modify
+    | these language lines according to your application's requirements.
+    |
+    */
+    'failed' => 'البيانات المعطاة لا توافق سجلاتنا.',
+    'throttle' => 'تجاوزت الحد الأقصى من المحاولات. الرجاء المحاولة مرة أخرى بعد :seconds seconds.',
+
+    /**
+     * Login & Register
+     */
+    'sign_up' => 'إنشاء حساب',
+    'log_in' => 'تسجيل الدخول',
+    'log_in_with' => 'تسجيل الدخول باستخدام :socialDriver',
+    'sign_up_with' => 'إنشاء حساب باستخدام :socialDriver',
+    'logout' => 'تسجيل الخروج',
+
+    'name' => 'الاسم',
+    'username' => 'اسم المستخدم',
+    'email' => 'البريد الإلكتروني',
+    'password' => 'كلمة المرور',
+    'password_confirm' => 'تأكيد كلمة المرور',
+    'password_hint' => 'يجب أن تكون أكثر من 5 حروف',
+    'forgot_password' => 'نسيت كلمة المرور؟',
+    'remember_me' => 'تذكرني',
+    'ldap_email_hint' => 'الرجاء إدخال عنوان بريد إلكتروني لاستخدامه مع الحساب.',
+    'create_account' => 'إنشاء حساب',
+    'social_login' => 'تسجيل الدخول باستخدام حسابات التواصل الاجتماعي',
+    'social_registration' => 'إنشاء حساب باستخدام حسابات التواصل الاجتماعي',
+    'social_registration_text' => 'إنشاء حساب والدخول باستخدام خدمة أخرى.',
+
+    'register_thanks' => 'شكراً لتسجيل حسابك!',
+    'register_confirm' => 'الرجاء مراجعة البريد الإلكتروني والضغط على زر التأكيد لاستخدام :appName.',
+    'registrations_disabled' => 'التسجيل مغلق حالياً',
+    'registration_email_domain_invalid' => 'المجال الخاص بالبريد الإلكتروني لا يملك حق الوصول لهذا التطبيق',
+    'register_success' => 'شكراً لإنشاء حسابكم! تم تسجيلكم ودخولكم للحساب الخاص بكم.',
+
+
+    /**
+     * Password Reset
+     */
+    'reset_password' => 'استعادة كلمة المرور',
+    'reset_password_send_instructions' => 'أدخل بريدك الإلكتروني بالأسفل وسيتم إرسال رسالة برابط لاستعادة كلمة المرور.',
+    'reset_password_send_button' => 'أرسل رابط الاستعادة',
+    'reset_password_sent_success' => 'تم إرسال رابط استعادة كلمة المرور إلى :email.',
+    'reset_password_success' => 'تمت استعادة كلمة المرور بنجاح.',
+
+    'email_reset_subject' => 'استعد كلمة المرور الخاصة بتطبيق :appName',
+    'email_reset_text' => 'تم إرسال هذه الرسالة بسبب تلقينا لطلب استعادة كلمة المرور الخاصة بحسابكم.',
+    'email_reset_not_requested' => 'إذا لم يتم طلب استعادة كلمة المرور من قبلكم, فلا حاجة لاتخاذ أية خطوات.',
+
+
+    /**
+     * Email Confirmation
+     */
+    'email_confirm_subject' => 'تأكيد بريدكم الإلكتروني لتطبيق :appName',
+    'email_confirm_greeting' => 'شكرا لانضمامكم إلى :appName!',
+    'email_confirm_text' => 'الرجاء تأكيد بريدكم الإلكتروني بالضغط على الزر أدناه:',
+    'email_confirm_action' => 'تأكيد البريد الإلكتروني',
+    'email_confirm_send_error' => 'تأكيد البريد الإلكتروني مطلوب ولكن النظام لم يستطع إرسال الرسالة. تواصل مع مشرف النظام للتأكد من إعدادات البريد.',
+    'email_confirm_success' => 'تم تأكيد بريدكم الإلكتروني!',
+    'email_confirm_resent' => 'تمت إعادة إرسال رسالة التأكيد. الرجاء مراجعة صندوق الوارد',
+
+    'email_not_confirmed' => 'لم يتم تأكيد البريد الإلكتروني',
+    'email_not_confirmed_text' => 'لم يتم بعد تأكيد عنوان البريد الإلكتروني.',
+    'email_not_confirmed_click_link' => 'الرجاء الضغط على الرابط المرسل إلى بريدكم الإلكتروني بعد تسجيلكم.',
+    'email_not_confirmed_resend' => 'إذا لم يتم إيجاد الرسالة, بإمكانكم إعادة إرسال رسالة التأكيد عن طريق تعبئة النموذج أدناه.',
+    'email_not_confirmed_resend_button' => 'إعادة إرسال رسالة التأكيد',
+];
\ No newline at end of file
diff --git a/resources/lang/ar/common.php b/resources/lang/ar/common.php
new file mode 100644 (file)
index 0000000..9c978b4
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+return [
+
+    /**
+     * Buttons
+     */
+    'cancel' => 'إلغاء',
+    'confirm' => 'تأكيد',
+    'back' => 'رجوع',
+    'save' => 'حفظ',
+    'continue' => 'استمرار',
+    'select' => 'تحديد',
+    'more' => 'المزيد',
+
+    /**
+     * Form Labels
+     */
+    'name' => 'الاسم',
+    'description' => 'الوصف',
+    'role' => 'الدور',
+    'cover_image' => 'صورة الغلاف',
+    'cover_image_description' => 'الصورة يجب أن تكون مقاربة لحجم 440×250 بكسل.',
+    
+    /**
+     * Actions
+     */
+    'actions' => 'إجراءات',
+    'view' => 'عرض',
+    'create' => 'إنشاء',
+    'update' => 'تحديث',
+    'edit' => 'تعديل',
+    'sort' => 'سرد',
+    'move' => 'نقل',
+    'copy' => 'نسخ',
+    'reply' => 'رد',
+    'delete' => 'حذف',
+    'search' => 'بحث',
+    'search_clear' => 'مسح البحث',
+    'reset' => 'إعادة تعيين',
+    'remove' => 'إزالة',
+    'add' => 'إضافة',
+
+    /**
+     * Misc
+     */
+    'deleted_user' => 'حذف مستخدم',
+    'no_activity' => 'لا يوجد نشاط لعرضه',
+    'no_items' => 'لا توجد عناصر متوفرة',
+    'back_to_top' => 'العودة للبداية',
+    'toggle_details' => 'عرض / إخفاء التفاصيل',
+    'toggle_thumbnails' => 'عرض / إخفاء الصور المصغرة',
+    'details' => 'التفاصيل',
+    'grid_view' => 'عرض شبكي',
+    'list_view' => 'عرض منسدل',
+
+    /**
+     * Header
+     */
+    'view_profile' => 'عرض الملف الشخصي',
+    'edit_profile' => 'تعديل الملف الشخصي',
+
+    /**
+     * Email Content
+     */
+    'email_action_help' => 'إذا واجهتكم مشكلة بضغط زر ":actionText" فبإمكانكم نسخ الرابط أدناه ولصقه بالمتصفح:',
+    'email_rights' => 'جميع الحقوق محفوظة',
+];
\ No newline at end of file
diff --git a/resources/lang/ar/components.php b/resources/lang/ar/components.php
new file mode 100644 (file)
index 0000000..f985589
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+return [
+
+    /**
+     * Image Manager
+     */
+    'image_select' => 'تحديد صورة',
+    'image_all' => 'الكل',
+    'image_all_title' => 'عرض جميع الصور',
+    'image_book_title' => 'عرض الصور المرفوعة لهذا الكتاب',
+    'image_page_title' => 'عرض الصور المرفوعة لهذه الصفحة',
+    'image_search_hint' => 'البحث باستخدام اسم الصورة',
+    'image_uploaded' => 'وقت الرفع :uploadedDate',
+    'image_load_more' => 'المزيد',
+    'image_image_name' => 'اسم الصورة',
+    'image_delete_used' => 'هذه الصورة مستخدمة بالصفحات أدناه.',
+    'image_delete_confirm' => 'اضغط زر الحذف مرة أخرى لتأكيد حذف هذه الصورة.',
+    'image_select_image' => 'تحديد الصورة',
+    'image_dropzone' => 'قم بإسقاط الصورة أو اضغط هنا للرفع',
+    'images_deleted' => 'تم حذف الصور',
+    'image_preview' => 'معاينة الصور',
+    'image_upload_success' => 'تم رفع الصورة بنجاح',
+    'image_update_success' => 'تم تحديث تفاصيل الصورة بنجاح',
+    'image_delete_success' => 'تم حذف الصورة بنجاح',
+    'image_upload_remove' => 'إزالة',
+
+    /**
+     * Code editor
+     */
+    'code_editor' => 'تعديل الشفرة',
+    'code_language' => 'لغة الشفرة',
+    'code_content' => 'محتويات الشفرة',
+    'code_save' => 'حفظ الشفرة',
+];
diff --git a/resources/lang/ar/entities.php b/resources/lang/ar/entities.php
new file mode 100644 (file)
index 0000000..5509938
--- /dev/null
@@ -0,0 +1,268 @@
+<?php
+return [
+
+    /**
+     * Shared
+     */
+    'recently_created' => 'أنشئت مؤخراً',
+    'recently_created_pages' => 'صفحات أنشئت مؤخراً',
+    'recently_updated_pages' => 'صفحات حُدثت مؤخراً',
+    'recently_created_chapters' => 'فصول أنشئت مؤخراً',
+    'recently_created_books' => 'كتب أنشئت مؤخراً',
+    'recently_update' => 'حُدثت مؤخراً',
+    'recently_viewed' => 'عُرضت مؤخراً',
+    'recent_activity' => 'نشاطات حديثة',
+    'create_now' => 'أنشئ الآن',
+    'revisions' => 'مراجعات',
+    'meta_revision' => 'مراجعة #:revisionCount',
+    'meta_created' => 'أنشئ :timeLength',
+    'meta_created_name' => 'أنشئ :timeLength بواسطة :user',
+    'meta_updated' => 'مُحدث :timeLength',
+    'meta_updated_name' => 'مُحدث :timeLength بواسطة :user',
+    'entity_select' => 'Entity Select', // جار البحث عن الترجمة الأنسب
+    'images' => 'صور',
+    'my_recent_drafts' => 'مسوداتي الحديثة',
+    'my_recently_viewed' => 'ما عرضته مؤخراً',
+    'no_pages_viewed' => 'لم تستعرض أي صفحات',
+    'no_pages_recently_created' => 'لم يتم إنشاء أي صفحات مؤخراً',
+    'no_pages_recently_updated' => 'لم يتم تحديث أي صفحات مؤخراً',
+    'export' => 'تصدير',
+    'export_html' => 'صفحة ويب',
+    'export_pdf' => 'ملف PDF',
+    'export_text' => 'ملف نص عادي',
+
+    /**
+     * Permissions and restrictions
+     */
+    'permissions' => 'الأذونات',
+    'permissions_intro' => 'في حال التفعيل, ستتم تبدية هذه الأذونات على أذونات الأدوار.',
+    'permissions_enable' => 'تفعيل الأذونات المخصصة',
+    'permissions_save' => 'حفظ الأذونات',
+
+    /**
+     * Search //
+     */
+    'search_results' => 'نتائج البحث',
+    'search_total_results_found' => 'عدد النتائج :count|مجموع النتائج :count',
+    'search_clear' => 'مسح البحث',
+    'search_no_pages' => 'لم يطابق بحثكم أي صفحة',
+    'search_for_term' => 'ابحث عن :term',
+    'search_more' => 'المزيد من النتائج',
+    'search_filters' => 'تصفية البحث',
+    'search_content_type' => 'نوع المحتوى',
+    'search_exact_matches' => 'نتائج مطابقة تماماً',
+    'search_tags' => 'بحث الوسوم',
+    'search_viewed_by_me' => 'تم استعراضها من قبلي',
+    'search_not_viewed_by_me' => 'لم يتم استعراضها من قبلي',
+    'search_permissions_set' => 'حزمة الأذونات',
+    'search_created_by_me' => 'أنشئت بواسطتي',
+    'search_updated_by_me' => 'حُدثت بواسطتي',
+    'search_updated_before' => 'حدثت قبل',
+    'search_updated_after' => 'حدثت بعد',
+    'search_created_before' => 'أنشئت قبل',
+    'search_created_after' => 'أنشئت بعد',
+    'search_set_date' => 'تحديد التاريخ',
+    'search_update' => 'تحديث البحث',
+
+    /**
+     * Books
+     */
+    'book' => 'كتاب',
+    'books' => 'كتب',
+    'x_books' => ':count كتاب|:count كتب',
+    'books_empty' => 'لم يتم إنشاء أي كتب',
+    'books_popular' => 'كتب رائجة',
+    'books_recent' => 'كتب حديثة',
+    'books_new' => 'كتب جديدة',
+    'books_popular_empty' => 'الكتب الأكثر رواجاً ستظهر هنا.',
+    'books_new_empty' => 'الكتب المنشأة مؤخراً ستظهر هنا.',
+    'books_create' => 'إنشاء كتاب جديد',
+    'books_delete' => 'حذف الكتاب',
+    'books_delete_named' => 'حذف كتاب :bookName',
+    'books_delete_explain' => 'سيتم حذف كتاب \':bookName\'. ستتم إزالة جميع الفصول والصفحات.',
+    'books_delete_confirmation' => 'تأكيد حذف الكتاب؟',
+    'books_edit' => 'تعديل الكتاب',
+    'books_edit_named' => 'تعديل كتاب :bookName',
+    'books_form_book_name' => 'اسم الكتاب',
+    'books_save' => 'حفظ الكتاب',
+    'books_permissions' => 'أذونات الكتاب',
+    'books_permissions_updated' => 'تم تحديث أذونات الكتاب',
+    'books_empty_contents' => 'لم يتم إنشاء أي صفحات أو فصول لهذا الكتاب.',
+    'books_empty_create_page' => 'إنشاء صفحة جديدة',
+    'books_empty_or' => 'أو',
+    'books_empty_sort_current_book' => 'فرز الكتاب الحالي',
+    'books_empty_add_chapter' => 'إضافة فصل',
+    'books_permissions_active' => 'أذونات الكتاب مفعلة',
+    'books_search_this' => 'البحث في هذا الكتاب',
+    'books_navigation' => 'تصفح الكتاب',
+    'books_sort' => 'فرز محتويات الكتاب',
+    'books_sort_named' => 'فرز كتاب :bookName',
+    'books_sort_show_other' => 'عرض كتب أخرى',
+    'books_sort_save' => 'حفظ الترتيب الجديد',
+
+    /**
+     * Chapters
+     */
+    'chapter' => 'فصل',
+    'chapters' => 'فصول',
+    'x_chapters' => ':count فصل|:count فصول',
+    'chapters_popular' => 'فصول رائجة',
+    'chapters_new' => 'فصل جديد',
+    'chapters_create' => 'إنشاء فصل جديد',
+    'chapters_delete' => 'حذف الفصل',
+    'chapters_delete_named' => 'حذف فصل :chapterName',
+    'chapters_delete_explain' => 'سيتم حذف فصل \':chapterName\'. جميع الصفحات ستزال وستتم إضافتها مباشرة للكتاب الرئيسي.',
+    'chapters_delete_confirm' => 'تأكيد حذف الفصل؟',
+    'chapters_edit' => 'تعديل الفصل',
+    'chapters_edit_named' => 'تعديل فصل :chapterName',
+    'chapters_save' => 'حفظ الفصل',
+    'chapters_move' => 'نقل الفصل',
+    'chapters_move_named' => 'نقل فصل :chapterName',
+    'chapter_move_success' => 'تم نقل الفصل إلى :bookName',
+    'chapters_permissions' => 'أذونات الفصل',
+    'chapters_empty' => 'لا توجد أي صفحات في هذا الفصل حالياً',
+    'chapters_permissions_active' => 'أذونات الفصل مفعلة',
+    'chapters_permissions_success' => 'تم تحديث أذونات الفصل',
+    'chapters_search_this' => 'البحث في هذا الفصل',
+
+    /**
+     * Pages
+     */
+    'page' => 'صفحة',
+    'pages' => 'صفحات',
+    'x_pages' => ':count صفحة|:count صفحات',
+    'pages_popular' => 'صفحات رائجة',
+    'pages_new' => 'صفحة جديدة',
+    'pages_attachments' => 'مرفقات',
+    'pages_navigation' => 'تصفح الصفحة',
+    'pages_delete' => 'حذف الصفحة',
+    'pages_delete_named' => 'حذف صفحة :pageName',
+    'pages_delete_draft_named' => 'حذف مسودة :pageName',
+    'pages_delete_draft' => 'حذف المسودة',
+    'pages_delete_success' => 'تم حذف الصفحة',
+    'pages_delete_draft_success' => 'تم حذف المسودة',
+    'pages_delete_confirm' => 'تأكيد حذف الصفحة؟',
+    'pages_delete_draft_confirm' => 'تأكيد حذف المسودة؟',
+    'pages_editing_named' => ':pageName قيد التعديل',
+    'pages_edit_toggle_header' => 'إظهار / إخفاء الترويسة',
+    'pages_edit_save_draft' => 'حفظ المسودة',
+    'pages_edit_draft' => 'تعديل مسودة الصفحة',
+    'pages_editing_draft' => 'المسودة قيد التعديل',
+    'pages_editing_page' => 'الصفحة قيد التعديل',
+    'pages_edit_draft_save_at' => 'تم خفظ المسودة في ',
+    'pages_edit_delete_draft' => 'حذف المسودة',
+    'pages_edit_discard_draft' => 'التخلص من المسودة',
+    'pages_edit_set_changelog' => 'تثبيت سجل التعديل',
+    'pages_edit_enter_changelog_desc' => 'ضع وصف مختصر للتعديلات التي تمت',
+    'pages_edit_enter_changelog' => 'أدخل سجل التعديل',
+    'pages_save' => 'حفظ الصفحة',
+    'pages_title' => 'عنوان الصفحة',
+    'pages_name' => 'اسم الصفحة',
+    'pages_md_editor' => 'المحرر',
+    'pages_md_preview' => 'معاينة',
+    'pages_md_insert_image' => 'إدخال صورة',
+    'pages_md_insert_link' => 'Insert Entity Link', // جار البحث عن الترجمة الأنسب
+    'pages_md_insert_drawing' => 'إدخال رسمة',
+    'pages_not_in_chapter' => 'صفحة ليست في فصل',
+    'pages_move' => 'نقل الصفحة',
+    'pages_move_success' => 'تم نقل الصفحة إلى ":parentName"',
+    'pages_copy' => 'نسخ الصفحة',
+    'pages_copy_desination' => 'نسخ مكان الوصول',
+    'pages_copy_success' => 'تم نسخ الصفحة بنجاح',
+    'pages_permissions' => 'أذونات الصفحة',
+    'pages_permissions_success' => 'تم تحديث أذونات الصفحة',
+    'pages_revision' => 'مراجعة',
+    'pages_revisions' => 'مراجعات الصفحة',
+    'pages_revisions_named' => 'مراجعات صفحة :pageName',
+    'pages_revision_named' => 'مراجعة صفحة :pageName',
+    'pages_revisions_created_by' => 'أنشئ بواسطة',
+    'pages_revisions_date' => 'تاريخ المراجعة',
+    'pages_revisions_number' => '#',
+    'pages_revisions_changelog' => 'سجل التعديل',
+    'pages_revisions_changes' => 'التعديلات',
+    'pages_revisions_current' => 'النسخة الحالية',
+    'pages_revisions_preview' => 'معاينة',
+    'pages_revisions_restore' => 'استرجاع',
+    'pages_revisions_none' => 'لا توجد مراجعات لهذه الصفحة',
+    'pages_copy_link' => 'نسخ الرابط',
+    'pages_edit_content_link' => 'تعديل المحتوى',
+    'pages_permissions_active' => 'أذونات الصفحة مفعلة',
+    'pages_initial_revision' => 'نشر مبدئي',
+    'pages_initial_name' => 'صفحة جديدة',
+    'pages_editing_draft_notification' => 'جار تعديل مسودة لم يتم حفظها من :timeDiff.',
+    'pages_draft_edited_notification' => 'تم تحديث هذه الصفحة منذ ذلك الوقت. من الأفضل التخلص من هذه المسودة.',
+    'pages_draft_edit_active' => [
+        'start_a' => ':count من المستخدمين بدأوا بتعديل هذه الصفحة',
+        'start_b' => ':userName بدأ بتعديل هذه الصفحة',
+        'time_a' => 'منذ أن تم تحديث هذه الصفحة',
+        'time_b' => 'في آخر :minCount دقيقة/دقائق',
+        'message' => ':start :time. Take care not to overwrite each other\'s updates!', // جار البحث عن الترجمة الأنسب
+    ],
+    'pages_draft_discarded' => 'تم التخلص من المسودة. تم تحديث المحرر بمحتوى الصفحة الحالي',
+
+    /**
+     * Editor sidebar
+     */
+    'page_tags' => 'وسوم الصفحة',
+    'chapter_tags' => 'وسوم الفصل',
+    'book_tags' => 'وسوم الكتاب',
+    'tag' => 'وسم',
+    'tags' =>  'وسوم',
+    'tag_value' => 'قيمة الوسم (اختياري)',
+    'tags_explain' => "إضافة الوسوم تساعد بترتيب وتقسيم المحتوى. \n من الممكن وضع قيمة لكل وسم لترتيب أفضل وأدق.",
+    'tags_add' => 'إضافة وسم آخر',
+    'attachments' => 'المرفقات',
+    'attachments_explain' => 'ارفع بعض الملفات أو أرفق بعض الروابط لعرضها بصفحتك. ستكون الملفات والروابط معروضة في الشريط الجانبي للصفحة.',
+    'attachments_explain_instant_save' => 'سيتم حفظ التغييرات هنا بلحظتها',
+    'attachments_items' => 'العناصر المرفقة',
+    'attachments_upload' => 'رفع ملف',
+    'attachments_link' => 'إرفاق رابط',
+    'attachments_set_link' => 'تحديد الرابط',
+    'attachments_delete_confirm' => 'اضغط على زر الحذف مرة أخرى لتأكيد حذف المرفق.',
+    'attachments_dropzone' => 'أسقط الملفات أو اضغط هنا لإرفاق ملف',
+    'attachments_no_files' => 'لم يتم رفع أي ملفات',
+    'attachments_explain_link' => 'بالإمكان إرفاق رابط في حال عدم تفضيل رفع ملف. قد يكون الرابط لصفحة أخرى أو لملف في أحد خدمات التخزين السحابي.',
+    'attachments_link_name' => 'اسم الرابط',
+    'attachment_link' => 'رابط المرفق',
+    'attachments_link_url' => 'Link to file', // جار البحث عن الترجمة الأنسب - هل المقصود الربط بالملف أو رابط يشير إلى ملف
+    'attachments_link_url_hint' => 'رابط الموقع أو الملف',
+    'attach' => 'Attach',
+    'attachments_edit_file' => 'تعديل الملف',
+    'attachments_edit_file_name' => 'اسم الملف',
+    'attachments_edit_drop_upload' => 'أسقط الملفات أو اضغط هنا للرفع والاستبدال',
+    'attachments_order_updated' => 'تم تحديث ترتيب المرفقات',
+    'attachments_updated_success' => 'تم تحديث تفاصيل المرفق',
+    'attachments_deleted' => 'تم حذف المرفق',
+    'attachments_file_uploaded' => 'تم رفع الملف بنجاح',
+    'attachments_file_updated' => 'تم تحديث الملف بنجاح',
+    'attachments_link_attached' => 'تم إرفاق الرابط بالصفحة بنجاح',
+
+    /**
+     * Profile View
+     */
+    'profile_user_for_x' => 'User for :time', // جار البحث عن الترجمة الأنسب
+    'profile_created_content' => 'المحتوى المنشأ',
+    'profile_not_created_pages' => 'لم يتم إنشاء أي صفحات بواسطة :userName',
+    'profile_not_created_chapters' => 'لم يتم إنشاء أي فصول بواسطة :userName',
+    'profile_not_created_books' => 'لم يتم إنشاء أي كتب بواسطة :userName',
+
+    /**
+     * Comments
+     */
+    'comment' => 'تعليق',
+    'comments' => 'تعليقات',
+    'comment_add' => 'إضافة تعليق',
+    'comment_placeholder' => 'ضع تعليقاً هنا',
+    'comment_count' => '{0} ا توجد تعليقات|{1} تعليق واحد|{2} تعليقان|[3,*] :count تعليقات',
+    'comment_save' => 'حفظ التعليق',
+    'comment_saving' => 'جار حفظ التعليق...',
+    'comment_deleting' => 'جار حذف التعليق...',
+    'comment_new' => 'تعليق جديد',
+    'comment_created' => 'تم التعليق :createDiff',
+    'comment_updated' => 'تم التحديث :updateDiff بواسطة :username',
+    'comment_deleted_success' => 'تم حذف التعليق',
+    'comment_created_success' => 'تمت إضافة التعليق',
+    'comment_updated_success' => 'تم تحديث التعليق',
+    'comment_delete_confirm' => 'تأكيد حذف التعليق؟',
+    'comment_in_reply_to' => 'رداً على :commentId',
+];
diff --git a/resources/lang/ar/errors.php b/resources/lang/ar/errors.php
new file mode 100644 (file)
index 0000000..019b1ac
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+
+return [
+
+    /**
+     * Error text strings.
+     */
+
+    // Permissions
+    'permission' => 'لم يؤذن لك بالدخول للصفحة المطلوبة.',
+    'permissionJson' => 'لم يؤذن لك بعمل الإجراء المطلوب.',
+
+    // Auth
+    'error_user_exists_different_creds' => 'يوجد مستخدم ببيانات مختلفة مسجل بالنظام للبريد الإلكتروني :email.',
+    'email_already_confirmed' => 'تم تأكيد البريد الإلكتروني من قبل, الرجاء محاولة تسجيل الدخول.',
+    'email_confirmation_invalid' => 'رابط التأكيد غير صحيح أو قد تم استخدامه من قبل, الرجاء محاولة التسجيل من جديد.',
+    'email_confirmation_expired' => 'صلاحية رابط التأكيد انتهت, تم إرسال رسالة تأكيد جديدة لعنوان البريد الإلكتروني.',
+    'ldap_fail_anonymous' => 'فشل الوصول إلى LDAP باستخدام الربط المجهول',
+    'ldap_fail_authed' => 'فشل الوصول إلى LDAP باستخدام dn و password المعطاة',
+    'ldap_extension_not_installed' => 'لم يتم تثبيت إضافة LDAP PHP',
+    'ldap_cannot_connect' => 'لا يمكن الاتصال بخادم ldap, فشل الاتصال المبدئي',
+    'social_no_action_defined' => 'لم يتم تعريف أي إجراء',
+    'social_login_bad_response' => "حصل خطأ خلال تسجيل الدخول باستخدام :socialAccount \n:error",
+    'social_account_in_use' => 'حساب :socialAccount قيد الاستخدام حالياً, الرجاء محاولة الدخول باستخدام خيار :socialAccount.',
+    'social_account_email_in_use' => 'البريد الإلكتروني :email مستخدم. إذا كان لديكم حساب فبإمكانكم ربط حساب :socialAccount من إعدادات ملفكم.',
+    'social_account_existing' => 'تم ربط حساب :socialAccount بملفكم من قبل.',
+    'social_account_already_used_existing' => 'حساب :socialAccount مستخدَم من قبل مستخدم آخر.',
+    'social_account_not_used' => 'حساب :socialAccount غير مرتبط بأي مستخدم. الرجاء ربطه من خلال إعدادات ملفكم. ',
+    'social_account_register_instructions' => 'إذا لم يكن لديكم حساب فيمكنكم التجسيل باستخدام خيار :socialAccount.',
+    'social_driver_not_found' => 'Social driver not found', // جار البحث عن الترجمة الأنسب
+    'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.', // جار البحث عن الترجمة الأنسب
+
+    // System
+    'path_not_writable' => 'لا يمكن الرفع إلى مسار :filePath. الرجاء التأكد من قابلية الكتابة إلى الخادم.',
+    'cannot_get_image_from_url' => 'لا يمكن الحصول على الصورة من :url',
+    'cannot_create_thumbs' => 'لا يمكن للخادم إنشاء صور مصغرة. الرجاء التأكد من تثبيت إضافة GD PHP.',
+    'server_upload_limit' => 'الخادم لا يسمح برفع ملفات بهذا الحجم. الرجاء محاولة الرفع بحجم أصغر.',
+    'uploaded'  => 'الخادم لا يسمح برفع ملفات بهذا الحجم. الرجاء محاولة الرفع بحجم أصغر.',
+    'image_upload_error' => 'حدث خطأ خلال رفع الصورة',
+    'image_upload_type_error' => 'صيغة الصورة المرفوعة غير صالحة',
+
+    // Attachments
+    'attachment_page_mismatch' => 'Page mismatch during attachment update', // جار البحث عن الترجمة الأنسب
+    'attachment_not_found' => 'لم يتم العثور على المرفق',
+
+    // Pages
+    'page_draft_autosave_fail' => 'فشل حفظ المسودة. الرجاء التأكد من وجود اتصال بالإنترنت قبل حفظ الصفحة',
+    'page_custom_home_deletion' => 'لا يمكن حذف الصفحة إذا كانت محددة كصفحة رئيسية',
+
+    // Entities
+    'entity_not_found' => 'Entity not found', // جار البحث عن الترجمة الأنسب
+    'book_not_found' => 'لم يتم العثور على الكتاب',
+    'page_not_found' => 'لم يتم العثور على الصفحة',
+    'chapter_not_found' => 'لم يتم العثور على الفصل',
+    'selected_book_not_found' => 'لم يتم العثور على الكتاب المحدد',
+    'selected_book_chapter_not_found' => 'لم يتم العثور على الكتاب أو الفصل المحدد',
+    'guests_cannot_save_drafts' => 'لا يمكن حفظ المسودات من قبل الضيوف',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'لا يمكن حذف المشرف الوحيد',
+    'users_cannot_delete_guest' => 'لا يمكن حذف المستخدم الضيف',
+
+    // Roles
+    'role_cannot_be_edited' => 'لا يمكن تعديل هذا الدور',
+    'role_system_cannot_be_deleted' => 'هذا الدور خاص بالنظام ولا يمكن حذفه',
+    'role_registration_default_cannot_delete' => 'لا يمكن حذف الدور إذا كان مسجل كالدور الأساسي بعد تسجيل الحساب',
+
+    // Comments
+    'comment_list' => 'حصل خطأ خلال جلب التعليقات.',
+    'cannot_add_comment_to_draft' => 'لا يمكن إضافة تعليقات على مسودة.',
+    'comment_add' => 'حصل خطاً خلال إضافة / تحديث التعليق.',
+    'comment_delete' => 'حصل خطأ خلال حذف التعليق.',
+    'empty_comment' => 'لايمكن إضافة تعليق فارغ.',
+
+    // Error pages
+    '404_page_not_found' => 'لم يتم العثور على الصفحة',
+    'sorry_page_not_found' => 'عفواً, لا يمكن العثور على الصفحة التي تبحث عنها.',
+    'return_home' => 'العودة للصفحة الرئيسية',
+    'error_occurred' => 'حدث خطأ',
+    'app_down' => ':appName لا يعمل حالياً',
+    'back_soon' => 'سيعود للعمل قريباً.',
+];
diff --git a/resources/lang/ar/pagination.php b/resources/lang/ar/pagination.php
new file mode 100644 (file)
index 0000000..9a1276a
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Pagination Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are used by the paginator library to build
+    | the simple pagination links. You are free to change them to anything
+    | you want to customize your views to better match your application.
+    |
+    */
+
+    'previous' => '&laquo; السابق',
+    'next'     => 'التالي &raquo;',
+
+];
diff --git a/resources/lang/ar/passwords.php b/resources/lang/ar/passwords.php
new file mode 100644 (file)
index 0000000..6af597f
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Password Reminder Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are the default lines which match reasons
+    | that are given by the password broker for a password update attempt
+    | has failed, such as for an invalid token or invalid new password.
+    |
+    */
+
+    'password' => 'يجب أن تتكون كلمة المرور من ستة أحرف على الأقل وأن تطابق التأكيد.',
+    'user' => "لم يتم العثور على مستخدم بعنوان البريد الإلكتروني المعطى.",
+    'token' => 'رابط تجديد كلمة المرور غير صحيح.',
+    'sent' => 'تم إرسال رابط تجديد كلمة المرور إلى بريدكم الإلكتروني!',
+    'reset' => 'تم تجديد كلمة المرور الخاصة بكم!',
+
+];
diff --git a/resources/lang/ar/settings.php b/resources/lang/ar/settings.php
new file mode 100755 (executable)
index 0000000..850776a
--- /dev/null
@@ -0,0 +1,131 @@
+<?php
+
+return [
+
+    /**
+     * Settings text strings
+     * Contains all text strings used in the general settings sections of BookStack
+     * including users and roles.
+     */
+
+    'settings' => 'الإعدادات',
+    'settings_save' => 'حفظ الإعدادات',
+    'settings_save_success' => 'تم حفظ الإعدادات',
+
+    /**
+     * App settings
+     */
+
+    'app_settings' => 'إعدادات التطبيق',
+    'app_name' => 'اسم التطبيق',
+    'app_name_desc' => 'سيتم عرض هذا الاسم في الترويسة وفي أي رسالة بريد إلكتروني.',
+    'app_name_header' => 'عرض اسم التطبيق في الترويسة؟',
+    'app_public_viewing' => 'السماح بالعرض على العامة؟',
+    'app_secure_images' => 'تفعيل حماية أكبر لرفع الصور؟',
+    'app_secure_images_desc' => 'لتحسين أداء النظام, ستكون جميع الصور متاحة للعامة. هذا الخيار يضيف سلسلة من الحروف والأرقام العشوائية صعبة التخمين إلى رابط الصورة. الرجاء التأكد من تعطيل فهرسة المسارات لمنع الوصول السهل.',
+    'app_editor' => 'محرر الصفحة',
+    'app_editor_desc' => 'الرجاء اختيار محرر النص الذي سيستخدم من قبل جميع المستخدمين لتحرير الصفحات.',
+    'app_custom_html' => 'Custom HTML head content', // جار البحث عن الترجمة الأنسب
+    'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the <head> section of every page. This is handy for overriding styles or adding analytics code.', // جار البحث عن الترجمة الأنسب
+    'app_logo' => 'شعار التطبيق',
+    'app_logo_desc' => 'يجب أن تكون الصورة بارتفاع 43 بكسل. <br>سيتم تصغير الصور الأكبر من ذلك.',
+    'app_primary_color' => 'اللون الأساسي للتطبيق',
+    'app_primary_color_desc' => 'يجب أن تكون القيمة من نوع hex. <br>اترك الخانة فارغة للرجوع للون الافتراضي.',
+    'app_homepage' => 'الصفحة الرئيسية للتطبيق',
+    'app_homepage_desc' => 'الرجاء اختيار صفحة لتصبح الصفحة الرئيسية بدل من الافتراضية. سيتم تجاهل جميع الأذونات الخاصة بالصفحة المختارة.',
+    'app_homepage_default' => 'شكل الصفحة الافتراضية المختارة',
+    'app_homepage_books' => 'أو من الممكن اختيار صفحة الكتب كصفحة رئيسية. سيتم استبدالها بأي صفحة سابقة تم اختيارها كصفحة رئيسية.',
+    'app_disable_comments' => 'تعطيل التعليقات',
+    'app_disable_comments_desc' => 'تعطيل التعليقات على جميع الصفحات داخل التطبيق. التعليقات الموجودة من الأصل لن تكون ظاهرة.',
+
+    /**
+     * Registration settings
+     */
+
+    'reg_settings' => 'إعدادات التسجيل',
+    'reg_allow' => 'السماح بالتسجيل؟',
+    'reg_default_role' => 'دور المستخدم الأساسي بعد التسجيل',
+    'reg_confirm_email' => 'فرض التأكيد عن طريق البريد الإلكتروني؟',
+    'reg_confirm_email_desc' => 'إذا تم استخدام قيود للمجال سيصبح التأكيد عن طريق البريد الإلكتروني إلزامي وسيتم تجاهل القيمة أسفله.',
+    'reg_confirm_restrict_domain' => 'تقييد التسجيل على مجال محدد',
+    'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application. <br> Note that users will be able to change their email addresses after successful registration.', // جار البحث عن الترجمة الأنسب
+    'reg_confirm_restrict_domain_placeholder' => 'لم يتم اختيار أي قيود',
+
+    /**
+     * Maintenance settings
+     */
+
+    'maint' => 'الصيانة',
+    'maint_image_cleanup' => 'تنظيف الصور',
+    'maint_image_cleanup_desc' => "Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.", // جار البحث عن الترجمة الأنسب
+    'maint_image_cleanup_ignore_revisions' => 'تجاهل الصور في المراجعات',
+    'maint_image_cleanup_run' => 'بدء التنظيف',
+    'maint_image_cleanup_warning' => 'يوجد عدد :count من الصور المحتمل عدم استخدامها. تأكيد حذف الصور؟',
+    'maint_image_cleanup_success' => 'تم إيجاد وحذف عدد :count من الصور المحتمل عدم استخدامها!',
+    'maint_image_cleanup_nothing_found' => 'لم يتم حذف أي شيء لعدم وجود أي صور غير مسمتخدمة',
+
+    /**
+     * Role settings
+     */
+
+    'roles' => 'الأدوار',
+    'role_user_roles' => 'أدوار المستخدمين',
+    'role_create' => 'إنشاء دور جديد',
+    'role_create_success' => 'تم إنشاء الدور بنجاح',
+    'role_delete' => 'حذف الدور',
+    'role_delete_confirm' => 'سيتم حذف الدور المسمى \':roleName\'.',
+    'role_delete_users_assigned' => 'This role has :userCount users assigned to it. If you would like to migrate the users from this role select a new role below.', // جار البحث عن الترجمة الأنسب
+    'role_delete_no_migration' => "لا تقم بترجيل المستخدمين",
+    'role_delete_sure' => 'تأكيد حذف الدور؟',
+    'role_delete_success' => 'تم حذف الدور بنجاح',
+    'role_edit' => 'تعديل الدور',
+    'role_details' => 'تفاصيل الدور',
+    'role_name' => 'اسم الدور',
+    'role_desc' => 'وصف مختصر للدور',
+    'role_external_auth_id' => 'External Authentication IDs', // جار البحث عن الترجمة الأنسب
+    'role_system' => 'أذونات النظام',
+    'role_manage_users' => 'إدارة المستخدمين',
+    'role_manage_roles' => 'إدارة الأدوار وأذوناتها',
+    'role_manage_entity_permissions' => 'إدارة جميع أذونات الكتب والفصول والصفحات',
+    'role_manage_own_entity_permissions' => 'إدارة الأذونات الخاصة بكتابك أو فصلك أو صفحاتك',
+    'role_manage_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_all' => 'الكل',
+    'role_own' => 'Own',
+    'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', // جار البحث عن الترجمة الأنسب
+    'role_save' => 'حفظ الدور',
+    'role_update_success' => 'تم تحديث الدور بنجاح',
+    'role_users' => 'مستخدمون داخل هذا الدور',
+    'role_users_none' => 'لم يتم تعيين أي مستخدمين لهذا الدور',
+
+    /**
+     * Users
+     */
+
+    'users' => 'المستخدمون',
+    'user_profile' => 'ملف المستخدم',
+    'users_add_new' => 'إضافة مستخدم جديد',
+    'users_search' => 'بحث عن مستخدم',
+    'users_role' => 'أدوار المستخدمين',
+    'users_external_auth_id' => 'External Authentication ID', // جار البحث عن الترجمة الأنسب
+    'users_password_warning' => 'الرجاء ملئ الحقل أدناه فقط في حال أردتم تغيير كلمة المرور:',
+    'users_system_public' => 'هذا المستخدم يمثل أي ضيف يقوم بزيارة شيء يخصك. لا يمكن استخدامه لتسجيل الدخول ولكن يتم تعيينه تلقائياً.',
+    'users_delete' => 'حذف المستخدم',
+    'users_delete_named' => 'حذف المستخدم :userName',
+    'users_delete_warning' => 'سيتم حذف المستخدم \':userName\' بشكل تام من النظام.',
+    'users_delete_confirm' => 'تأكيد حذف المستخدم؟',
+    'users_delete_success' => 'تم حذف المستخدم بنجاح',
+    'users_edit' => 'تعديل المستخدم',
+    'users_edit_profile' => 'تعديل الملف',
+    'users_edit_success' => 'تم تحديث المستخدم بنجاح',
+    'users_avatar' => 'صورة المستخدم',
+    'users_avatar_desc' => 'يجب أن تكون الصورة مربعة ومقاربة لحجم 256 بكسل',
+    'users_preferred_language' => 'اللغة المفضلة',
+    'users_social_accounts' => 'الحسابات الاجتماعية',
+    'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not previously authorized access. Revoke access from your profile settings on the connected social account.', // جار البحث عن الترجمة الأنسب
+    'users_social_connect' => 'ربط الحساب',
+    'users_social_disconnect' => 'فصل الحساب',
+    'users_social_connected' => 'تم ربط حساب :socialAccount بملفك بنجاح.',
+    'users_social_disconnected' => 'تم فصل حساب :socialAccount من ملفك بنجاح.',
+];
diff --git a/resources/lang/ar/validation.php b/resources/lang/ar/validation.php
new file mode 100644 (file)
index 0000000..47035a9
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Validation Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines contain the default error messages used by
+    | the validator class. Some of these rules have multiple versions such
+    | as the size rules. Feel free to tweak each of these messages here.
+    |
+    */
+
+    'accepted'             => 'يجب الموافقة على :attribute.',
+    'active_url'           => ':attribute ليس رابط صالح.',
+    'after'                => 'يجب أن يكون التاريخ :attribute بعد :date.',
+    'alpha'                => 'يجب أن يقتصر :attribute على الحروف فقط.',
+    'alpha_dash'           => 'يجب أن يقتصر :attribute على حروف أو أرقام أو شرطات فقط.',
+    'alpha_num'            => 'يجب أن يقتصر :attribute على الحروف والأرقام فقط.',
+    'array'                => 'The :attribute must be an array.', // جار البحث عن الترجمة الأنسب
+    'before'               => 'يجب أن يكون التاريخ :attribute قبل :date.',
+    'between'              => [
+        'numeric' => 'يجب أن يكون :attribute بين :min و :max.',
+        'file'    => 'يجب أن يكون :attribute بين :min و :max كيلو بايت.',
+        'string'  => 'يجب أن يكون :attribute بين :min و :max حرف / حروف.',
+        'array'   => 'يجب أن يكون :attribute بين :min و :max عنصر / عناصر.',
+    ],
+    'boolean'              => 'The :attribute field must be true or false.', // جار البحث عن الترجمة الأنسب
+    'confirmed'            => ':attribute غير مطابق.',
+    'date'                 => ':attribute ليس تاريخ صالح.',
+    'date_format'          => ':attribute لا يطابق الصيغة :format.',
+    'different'            => 'يجب أن يكون :attribute مختلف عن :other.',
+    'digits'               => 'يجب أن يكون :attribute بعدد :digits خانات.',
+    'digits_between'       => 'يجب أن يكون :attribute بعدد خانات بين :min و :max.',
+    'email'                => 'يجب أن يكون :attribute عنوان بريد إلكتروني صالح.',
+    'filled'               => 'حقل :attribute مطلوب.',
+    'exists'               => ':attribute المحدد غير صالح.',
+    'image'                => 'يجب أن يكون :attribute صورة.',
+    'in'                   => ':attribute المحدد غير صالح.',
+    'integer'              => 'يجب أن يكون :attribute عدد صحيح.',
+    'ip'                   => 'يجب أن يكون :attribute عنوان IP صالح.',
+    'max'                  => [
+        'numeric' => 'يجب ألا يكون :attribute أكبر من :max.',
+        'file'    => 'يجب ألا يكون :attribute أكبر من :max كيلو بايت.',
+        'string'  => 'يجب ألا يكون :attribute أكثر من :max حرف / حروف.',
+        'array'   => 'يجب ألا يحتوي :attribute على أكثر من :max عنصر / عناصر.',
+    ],
+    'mimes'                => 'يجب أن يكون :attribute ملف من نوع: :values.',
+    'min'                  => [
+        'numeric' => 'يجب أن يكون :attribute على الأقل :min.',
+        'file'    => 'يجب أن يكون :attribute على الأقل :min كيلو بايت.',
+        'string'  => 'يجب أن يكون :attribute على الأقل :min حرف / حروف.',
+        'array'   => 'يجب أن يحتوي :attribute على :min عنصر / عناصر كحد أدنى.',
+    ],
+    'not_in'               => ':attribute المحدد غير صالح.',
+    'numeric'              => 'يجب أن يكون :attribute رقم.',
+    'regex'                => 'صيغة :attribute غير صالحة.',
+    'required'             => 'حقل :attribute مطلوب.',
+    'required_if'          => 'حقل :attribute مطلوب عندما يكون :other :value.',
+    'required_with'        => 'حقل :attribute مطلوب عندما تكون :values موجودة.',
+    'required_with_all'    => 'حقل :attribute مطلوب عندما تكون :values موجودة.',
+    'required_without'     => 'حقل :attribute مطلوب عندما تكون :values غير موجودة.',
+    'required_without_all' => 'حقل :attribute مطلوب عندما لا يكون أي من :values موجودة.',
+    'same'                 => 'يجب تطابق :attribute مع :other.',
+    'size'                 => [
+        'numeric' => 'يجب أن يكون :attribute بحجم :size.',
+        'file'    => 'يجب أن يكون :attribute بحجم :size كيلو بايت.',
+        'string'  => 'يجب أن يكون :attribute بعدد :size حرف / حروف.',
+        'array'   => 'يجب أن يحتوي :attribute على :size عنصر / عناصر.',
+    ],
+    'string'               => 'The :attribute must be a string.', // جار البحث عن الترجمة الأنسب
+    'timezone'             => 'يجب أن تكون :attribute منطقة صالحة.',
+    'unique'               => 'تم حجز :attribute من قبل.',
+    'url'                  => 'صيغة :attribute غير صالحة.',
+
+    /*
+    |--------------------------------------------------------------------------
+    | Custom Validation Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | Here you may specify custom validation messages for attributes using the
+    | convention "attribute.rule" to name the lines. This makes it quick to
+    | specify a specific custom language line for a given attribute rule.
+    |
+    */
+
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'يجب تأكيد كلمة المرور',
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Custom Validation Attributes
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are used to swap attribute place-holders
+    | with something more reader friendly such as E-Mail Address instead
+    | of "email". This simply helps us make messages a little cleaner.
+    |
+    */
+
+    'attributes' => [],
+
+];
index 96aaa9b0ef761e887265220003257a5a721d7e7a..7c27be17b175c029aac83edc20d1c5818e2878c5 100644 (file)
@@ -256,4 +256,11 @@ return [
     'comment_updated_success' => 'Kommentar aktualisiert',
     'comment_delete_confirm' => 'Möchten Sie diesen Kommentar wirklich löschen?',
     'comment_in_reply_to' => 'Antwort auf :commentId',
+
+    /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'Sind Sie sicher, dass Sie diese Revision löschen wollen?',
+    'revision_delete_success' => 'Revision gelöscht',
+    'revision_cannot_delete_latest' => 'Die letzte Version kann nicht gelöscht werden.'
 ];
index 187fe1e53fb7c687daf54bf592afee33e1a40e5a..153ae33f0a6124f6c3199252b72f50e85ff70e6c 100644 (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',
 ];
index c2744d906285b60af537af44f0fd2dfd566baa8e..8e86129e2d49581fb8e025e8d41b8fb41e72a9d0 100644 (file)
@@ -52,6 +52,7 @@ return [
     'details' => 'Details',
     'grid_view' => 'Grid View',
     'list_view' => 'List View',
+    'default' => 'Default',
 
     /**
      * Header
index 93025ffd4e235be72fa11ca297ccab83feff1a5f..4f110b72411a3b716ca28ed9e8c671c3a9f1a150 100644 (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)',
@@ -265,4 +298,11 @@ return [
     'comment_updated_success' => 'Comment updated',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
     'comment_in_reply_to' => 'In reply to :commentId',
+
+    /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'Are you sure you want to delete this revision?',
+    'revision_delete_success' => 'Revision deleted',
+    'revision_cannot_delete_latest' => 'Cannot delete the latest revision.'
 ];
\ No newline at end of file
index a86a1cdfc8dadab7d88c704992a7f84e67e45619..fb09841cfd5a61290bb218d81474341c17cf191b 100644 (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',
index d6fbb6107c51454965252c8c605ab00dc81dac3a..46ef8d29fc62952865bf4084dba6341edb5cdc2d 100755 (executable)
@@ -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',
@@ -135,6 +135,7 @@ return [
     ///////////////////////////////////
     'language_select' => [
         'en' => 'English',
+        'ar' => 'العربية',
         'de' => 'Deutsch',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
@@ -148,7 +149,7 @@ return [
         'it' => 'Italian',
         'ru' => 'Русский',
         'zh_CN' => '简体中文',
-           'zh_TW' => '繁體中文'
+        'zh_TW' => '繁體中文'
     ]
     ///////////////////////////////////
 ];
index 8c5c9f07f46181ee807f8a06654d82bb5acc2ae2..a84d72fffe689bd0c44e7a24b5c0679e02c96a29 100644 (file)
@@ -265,4 +265,11 @@ return [
     'comment_updated_success' => 'Comentario actualizado',
     'comment_delete_confirm' => '¿Está seguro de que quiere borrar este comentario?',
     'comment_in_reply_to' => 'En respuesta a :commentId',
+
+    /**
+     * Revision
+     */
+    'revision_delete_confirm' => '¿Está seguro de que desea eliminar esta revisión?',
+    'revision_delete_success' => 'Revisión eliminada',
+    'revision_cannot_delete_latest' => 'No se puede eliminar la última revisión.'
 ];
index 371f1b7aa458bab2b0e8a6888967667854c93c0b..91d156e53452cd018c2af0253561044031fc7b9d 100644 (file)
@@ -265,4 +265,11 @@ return [
     'comment_updated_success' => 'Comentario actualizado',
     'comment_delete_confirm' => '¿Está seguro que quiere borrar este comentario?',
     'comment_in_reply_to' => 'En respuesta a :commentId',
+
+     /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'Are you sure you want to delete this revision?',
+    'revision_delete_success' => 'Revisión eliminada',
+    'revision_cannot_delete_latest' => 'No se puede eliminar la última revisión.'
 ];
index b2009777520f2b437e920bd839ba3b9034377004..deee70ee44c745ecd12a64b3a7bf3f8815bb2d0c 100644 (file)
@@ -265,4 +265,11 @@ return [
     'comment_updated_success' => 'Commentaire mis à jour',
     'comment_delete_confirm' => 'Etes-vous sûr de vouloir supprimer ce commentaire ?',
     'comment_in_reply_to' => 'En réponse à :commentId',
+
+     /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'Êtes-vous sûr de vouloir supprimer cette révision?',
+    'revision_delete_success' => 'Révision supprimée',
+    'revision_cannot_delete_latest' => 'Impossible de supprimer la dernière révision.'
 ];
\ No newline at end of file
index 1941ffb1e62a98193c707bb22ce1dc731e96063c..ad1733b9161f7262cb6a453880a6517586e49ff2 100755 (executable)
@@ -260,4 +260,11 @@ return [
     'comment_updated_success' => 'Commento aggiornato',
     'comment_delete_confirm' => 'Sei sicuro di voler elminare questo commento?',
     'comment_in_reply_to' => 'In risposta a :commentId',
+
+     /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'Sei sicuro di voler eliminare questa revisione?',
+    'revision_delete_success' => 'Revisione cancellata',
+    'revision_cannot_delete_latest' => 'Impossibile eliminare l\'ultima revisione.'
 ];
\ No newline at end of file
index c08c4998bdd2c2412f410e5df326c309208d1fc0..f177154f442ff37d8bfed961dc09952c625fcb0f 100644 (file)
@@ -257,4 +257,11 @@ return [
     'comment_updated_success' => 'コメントを更新しました',
     'comment_delete_confirm' => '本当にこのコメントを削除しますか?',
     'comment_in_reply_to' => ':commentIdへ返信',
+
+     /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'このリビジョンを削除しますか?',
+    'revision_delete_success' => 'リビジョンを削除しました',
+    'revision_cannot_delete_latest' => '最新のリビジョンを削除できません。'
 ];
index a807c84cea93d1eddecd78c286958e28647f3d85..29bb11a37849e0dca529b56c5555ba3761fb01b8 100644 (file)
@@ -259,4 +259,11 @@ return [
     'comment_updated_success' => 'Reactie bijgewerkt',
     'comment_delete_confirm' => 'Zeker reactie verwijderen?',
     'comment_in_reply_to' => 'Antwoord op :commentId',
+
+     /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'Weet u zeker dat u deze revisie wilt verwijderen?',
+    'revision_delete_success' => 'Revisie verwijderd',
+    'revision_cannot_delete_latest' => 'Kan de laatste revisie niet verwijderen.'
 ];
index 0407b139682ec4becf561920dc633d69264c4b7e..8b53591f6181693cd67740f728697b2e62d636a5 100644 (file)
@@ -257,4 +257,11 @@ return [
     'comment_updated_success' => 'Komentarz zaktualizowany',
     'comment_delete_confirm' => 'Czy na pewno chcesz usunąc ten komentarz?',
     'comment_in_reply_to' => 'W odpowiedzi na :commentId',
+
+     /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'Czy na pewno chcesz usunąć tę wersję?',
+    'revision_delete_success' => 'Usunięto wersję',
+    'revision_cannot_delete_latest' => 'Nie można usunąć najnowszej wersji.'
 ];
\ No newline at end of file
index 2f77e5490f257d7bcaa3544dd48214198de2f9a8..7e0088f27d055ce131a62b1e967bc881b09887e5 100644 (file)
@@ -265,4 +265,11 @@ return [
     'comment_updated_success' => 'Comentário editado',
     'comment_delete_confirm' => 'Você tem certeza de que quer deletar este comentário?',
     'comment_in_reply_to' => 'Em resposta à :commentId',
+
+    /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'Tem certeza de que deseja excluir esta revisão?',
+    'revision_delete_success' => 'Revisão excluída',
+    'revision_cannot_delete_latest' => 'Não é possível excluir a revisão mais recente.'
 ];
\ No newline at end of file
index a0322d622ea2391cc59525ff7ba7bba94f63d5ab..9c3517bebe51f697ef9d5c21ce751a7b413ae685 100644 (file)
@@ -258,4 +258,11 @@ return [
     'comment_updated_success' => 'Комментарий обновлён',
     'comment_delete_confirm' => 'Вы уверенны, что хотите удалить этот комментарий?',
     'comment_in_reply_to' => 'В ответ на :commentId',
+
+    /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'Вы действительно хотите удалить эту ревизию?',
+    'revision_delete_success' => 'Редактирование удалено',
+    'revision_cannot_delete_latest' => 'Не удается удалить последнюю версию.'
 ];
\ No newline at end of file
index 8f9a57d1f760dc8c90dbac5939cc71c2f07bc23a..7fbbaf2e2676ed0c0b62477ca8c05cdb02a3aefd 100644 (file)
@@ -232,4 +232,11 @@ return [
     'comments' => 'Komentáre',
     'comment_placeholder' => 'Tu zadajte svoje pripomienky',
     'comment_save' => 'Uložiť komentár',
+
+    /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'Naozaj chcete túto revíziu odstrániť?',
+    'revision_delete_success' => 'Revízia bola vymazaná',
+    'revision_cannot_delete_latest' => 'Nie je možné vymazať poslednú revíziu.'
 ];
index 3a2d1a2c6cbeaa1bcbca524698305df1b0ccfa90..8c09bd37709708282429c1dc49552ffecd297c47 100644 (file)
@@ -265,4 +265,11 @@ return [
     'comment_updated_success' => 'Kommentaren har uppdaterats',
     'comment_delete_confirm' => 'Är du säker på att du vill ta bort den här kommentaren?',
     'comment_in_reply_to' => 'Som svar på :commentId',
+
+    /**
+     * Revision
+     */
+    'revision_delete_confirm' => 'Är du säker på att du vill radera den här versionen?',
+    'revision_delete_success' => 'Revisionen raderad',
+    'revision_cannot_delete_latest' => 'Det går inte att ta bort den senaste versionen.'
 ];
\ No newline at end of file
index eed6b9532f770d97c665056eb27151042b377f0d..734a47a5a7064598828d60dafbef7432e203d9f9 100644 (file)
@@ -258,4 +258,11 @@ return [
     'comment_updated_success' => '评论已更新',
     'comment_delete_confirm' => '你确定要删除这条评论?',
     'comment_in_reply_to' => '回复 :commentId',
+
+    /**
+     * Revision
+     */
+    'revision_delete_confirm' => '您确定要删除此修订版吗?',
+    'revision_delete_success' => '修订删除',
+    'revision_cannot_delete_latest' => '无法删除最新版本。'
 ];
index 664917eaa686fa5e56cf16df857877f6e0a02761..1c4d526fd7edb3603f5c0d9595a9f1e7b00333bd 100644 (file)
@@ -259,4 +259,11 @@ return [
     'comment_updated_success' => '評論已更新',
     'comment_delete_confirm' => '你確定要刪除這條評論?',
     'comment_in_reply_to' => '回覆 :commentId',
+
+    /**
+     * Revision
+     */
+    'revision_delete_confirm' => '您確定要刪除此修訂版嗎?',
+    'revision_delete_success' => '修訂刪除',
+    'revision_cannot_delete_latest' => '無法刪除最新版本。'
 ];
index 8f6c2eb463259fabf3c6f80cd045c716c0496f40..e6d0b776101f2773ee254883000ff990f129e796 100644 (file)
 
     @include('partials/custom-styles')
 
-    @if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings')
-        <!-- Custom user content -->
-        {!! setting('app-custom-head') !!}
-        <!-- End custom user content -->
-    @endif
+    @include('partials.custom-head')
 </head>
 <body class="@yield('body-class')" ng-app="bookStack">
 
@@ -33,7 +29,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 +39,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 +48,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>
index 462ad7991a4bab5fddd24cb7e6593238f18fad7b..18440a74da82b8ecfc47e78f3e806cb8369f61ab 100644 (file)
@@ -28,6 +28,7 @@
         }
     </style>
     @yield('head')
+    @include('partials.custom-head')
 </head>
 <body>
 <div class="container">
index 1c2056a79124d6c7d0f2ec35a46f8f813930ef48..9459cc0088b9864c6badfa5a44246565b47e84ab 100644 (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>
\ No newline at end of file
index d0a2eb2f706aeeb19c82c7423f2e3670b0fcadb0..e5845b4956ba0b9db8a3349dd613f5342f8d83e8 100644 (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))
index 61df7ab8d1bd386fb492168c98fc0e872ccf4005..63eb9b9d3f4abf781e53d96142e84cbac8d45d2b 100644 (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
index 2c15fbd57d11d54bace8a135f1cff3c46e350bf7..8f710c0ecbdbd01e3f0b014e618a773d3bf508dd 100644 (file)
@@ -21,6 +21,7 @@
         }
     </style>
     @yield('head')
+    @include('partials.custom-head')
 </head>
 <body>
 <div class="container">
diff --git a/resources/views/common/home-shelves.blade.php b/resources/views/common/home-shelves.blade.php
new file mode 100644 (file)
index 0000000..3ae055b
--- /dev/null
@@ -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
index bbddb072da0e60e09bd829ca2378bfa77000e0c2..cc20fc68e2d91d97a7dc89236bb82e052f86fb9b 100644 (file)
@@ -10,7 +10,7 @@
 
 @section('body')
 
-    <div class="container">
+    <div class="container" id="home-default">
         <div class="row">
 
             <div class="col-sm-4">
index 8ed5b391ab8e8aa42dcc83c43395b911a8ee9387..ca708f8e0c99e17bd9854ba5201c52bd75c46597 100644 (file)
@@ -10,6 +10,7 @@
         @endif
     </style>
     @yield('head')
+    @include('partials.custom-head')
 </head>
 <body>
 <div class="container" id="page-show">
index 58afdfca7260e53569d92f845b4986cb43123f81..38e2eae39a4ea7d9eea7bed8a167d44313754fbc 100644 (file)
@@ -4,6 +4,7 @@
      drawio-enabled="{{ config('services.drawio') ? 'true' : 'false' }}"
      editor-type="{{ setting('app-editor') }}"
      page-id="{{ $model->id or 0 }}"
+     text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
      page-new-draft="{{ $model->draft or 0 }}"
      page-update-draft="{{ $model->isDraft or 0 }}">
 
index b3fdf11ece5c2df60f89ba9c08e1054b017f6e75..e13632c1ec86a41b3c1818be59add6b46682679f 100644 (file)
@@ -1,4 +1,4 @@
-<div>
+<div dir="auto">
 
     <h1 class="break-text" v-pre id="bkmrk-page-title">{{$page->name}}</h1>
 
index d07dc6fcc60d439bd2742eba7a5f02823a494533..72017467ed133b41f945e72f12d8c59ab8151908 100644 (file)
                                 <td> @if($revision->createdBy) {{ $revision->createdBy->name }} @else {{ trans('common.deleted_user') }} @endif</td>
                                 <td><small>{{ $revision->created_at->format('jS F, Y H:i:s') }} <br> ({{ $revision->created_at->diffForHumans() }})</small></td>
                                 <td>{{ $revision->summary }}</td>
-                                <td>
+                                <td class="actions">
                                     <a href="{{ $revision->getUrl('changes') }}" target="_blank">{{ trans('entities.pages_revisions_changes') }}</a>
                                     <span class="text-muted">&nbsp;|&nbsp;</span>
 
+
                                     @if ($index === 0)
                                         <a target="_blank" href="{{ $page->getUrl() }}"><i>{{ trans('entities.pages_revisions_current') }}</i></a>
                                     @else
                                         <a href="{{ $revision->getUrl() }}" target="_blank">{{ trans('entities.pages_revisions_preview') }}</a>
                                         <span class="text-muted">&nbsp;|&nbsp;</span>
                                         <a href="{{ $revision->getUrl('restore') }}">{{ trans('entities.pages_revisions_restore') }}</a>
+                                        <span class="text-muted">&nbsp;|&nbsp;</span>
+                                        <div dropdown class="dropdown-container">
+                                            <a dropdown-toggle>{{ trans('common.delete') }}</a>
+                                            <ul>
+                                                <li class="padded"><small class="text-muted">{{trans('entities.revision_delete_confirm')}}</small></li>
+                                                <li>
+                                                    <form action="{{ $revision->getUrl('/delete/') }}" method="POST">
+                                                        {!! csrf_field() !!}
+                                                        <input type="hidden" name="_method" value="DELETE">
+                                                        <button type="submit" class="text-button neg">@icon('delete'){{ trans('common.delete') }}</button>
+                                                    </form>
+                                                </li>
+                                            </ul>
+                                        </div>
                                     @endif
                                 </td>
                             </tr>
diff --git a/resources/views/partials/custom-head.blade.php b/resources/views/partials/custom-head.blade.php
new file mode 100644 (file)
index 0000000..dd7cc41
--- /dev/null
@@ -0,0 +1,5 @@
+@if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings')
+    <!-- Custom user content -->
+    {!! setting('app-custom-head') !!}
+    <!-- End custom user content -->
+@endif
\ No newline at end of file
index 272aa3dc110b0237cad5e4dd444f3b5fc6f769ec..0b9382f59be427f88b6c235c55ab075d87dce1e3 100644 (file)
@@ -19,4 +19,4 @@
         color: {{ setting('app-color') }};
         fill: {{ setting('app-color') }};
     }
-</style>
\ No newline at end of file
+</style>
index c90b953ea4b75f9140460d9d93feb4478b55726d..371f38d71c6b5e0c7a9de5a09d1c5e61f7ec38cc 100644 (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)
index 64017e6e00ec9ceb71b538313b0ada29a9e0f8c5..3c563a61c975fba2169993658230de7153a001ec 100644 (file)
                             <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>
 
index 6a8e27487c7d307508f38314cc69269dcaaeb951..619229a655067328ae4f088f745550ed1dbbd761 100644 (file)
                     <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>
                             <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 (file)
index 0000000..91b4252
--- /dev/null
@@ -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 (file)
index 0000000..32e40a4
--- /dev/null
@@ -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
\ 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 (file)
index 0000000..f3ad624
--- /dev/null
@@ -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
\ 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 (file)
index 0000000..ab88051
--- /dev/null
@@ -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
\ 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 (file)
index 0000000..462ad79
--- /dev/null
@@ -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 (file)
index 0000000..fb6fee1
--- /dev/null
@@ -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 (file)
index 0000000..b70b516
--- /dev/null
@@ -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 (file)
index 0000000..a887a84
--- /dev/null
@@ -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 (file)
index 0000000..0b8e79f
--- /dev/null
@@ -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 (file)
index 0000000..ff11d2d
--- /dev/null
@@ -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 (file)
index 0000000..472078a
--- /dev/null
@@ -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
diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php
new file mode 100644 (file)
index 0000000..2aae2c6
--- /dev/null
@@ -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 (file)
index 0000000..785e8ca
--- /dev/null
@@ -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
index c4e7469fee69a598fc81256525a9968a757b35de..d3c5f46d3ccdce717e167d4e545b4f19639feb68 100644 (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 () {
 
@@ -61,6 +76,7 @@ Route::group(['middleware' => 'auth'], function () {
         Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}', 'PageController@showRevision');
         Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', 'PageController@showRevisionChanges');
         Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageController@restoreRevision');
+        Route::delete('/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', 'PageController@destroyRevision');
 
         // Chapters
         Route::get('/{bookSlug}/chapter/{chapterSlug}/create-page', 'PageController@create');
@@ -79,7 +95,6 @@ Route::group(['middleware' => 'auth'], function () {
         Route::put('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@restrict');
         Route::get('/{bookSlug}/chapter/{chapterSlug}/delete', 'ChapterController@showDelete');
         Route::delete('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@destroy');
-
     });
 
     // User Profile routes
@@ -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');
index e3494d073e84ff59d52201f6c4ebe2e6242fd783..5bfe0c222cb04e2dd35baacc35099b2ee31a5537 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests;
 
-class SocialAuthTest extends BrowserKitTest
+class SocialAuthTest extends TestCase
 {
 
     public function test_social_registration()
@@ -25,11 +25,11 @@ class SocialAuthTest extends BrowserKitTest
         $mockSocialUser->shouldReceive('getName')->once()->andReturn($user->name);
         $mockSocialUser->shouldReceive('getAvatar')->once()->andReturn('avatar_placeholder');
 
-        $this->visit('/register/service/google');
-        $this->visit('/login/service/google/callback');
-        $this->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email]);
+        $this->get('/register/service/google');
+        $this->get('/login/service/google/callback');
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email]);
         $user = $user->whereEmail($user->email)->first();
-        $this->seeInDatabase('social_accounts', ['user_id' => $user->id]);
+        $this->assertDatabaseHas('social_accounts', ['user_id' => $user->id]);
     }
 
     public function test_social_login()
@@ -53,17 +53,21 @@ class SocialAuthTest extends BrowserKitTest
         $mockSocialDriver->shouldReceive('redirect')->twice()->andReturn(redirect('/'));
 
         // Test login routes
-        $this->visit('/login')->seeElement('#social-login-google')
-            ->click('#social-login-google')
-            ->seePageIs('/login');
+        $resp = $this->get('/login');
+        $resp->assertElementExists('a#social-login-google[href$="/login/service/google"]');
+        $resp = $this->followingRedirects()->get("/login/service/google");
+        $resp->assertSee('login-form');
 
         // Test social callback
-        $this->visit('/login/service/google/callback')->seePageIs('/login')
-            ->see(trans('errors.social_account_not_used', ['socialAccount' => 'Google']));
+        $resp = $this->followingRedirects()->get('/login/service/google/callback');
+        $resp->assertSee('login-form');
+        $resp->assertSee(trans('errors.social_account_not_used', ['socialAccount' => 'Google']));
+
+        $resp = $this->get('/login');
+        $resp->assertElementExists('a#social-login-github[href$="/login/service/github"]');
+        $resp = $this->followingRedirects()->get("/login/service/github");
+        $resp->assertSee('login-form');
 
-        $this->visit('/login')->seeElement('#social-login-github')
-        ->click('#social-login-github')
-        ->seePageIs('/login');
 
         // Test social callback with matching social account
         \DB::table('social_accounts')->insert([
@@ -71,7 +75,77 @@ class SocialAuthTest extends BrowserKitTest
             'driver' => 'github',
             'driver_id' => 'logintest123'
         ]);
-        $this->visit('/login/service/github/callback')->seePageIs('/');
+        $resp = $this->followingRedirects()->get('/login/service/github/callback');
+        $resp->assertDontSee("login-form");
+    }
+
+    public function test_social_autoregister()
+    {
+        config([
+            'services.google.client_id' => 'abc123', 'services.google.client_secret' => '123abc',
+            'APP_URL' => 'http://localhost'
+        ]);
+
+        $user = factory(\BookStack\User::class)->make();
+        $mockSocialite = \Mockery::mock('Laravel\Socialite\Contracts\Factory');
+        $this->app['Laravel\Socialite\Contracts\Factory'] = $mockSocialite;
+        $mockSocialDriver = \Mockery::mock('Laravel\Socialite\Contracts\Provider');
+        $mockSocialUser = \Mockery::mock('\Laravel\Socialite\Contracts\User');
+
+        $mockSocialUser->shouldReceive('getId')->times(4)->andReturn(1);
+        $mockSocialUser->shouldReceive('getEmail')->times(2)->andReturn($user->email);
+        $mockSocialUser->shouldReceive('getName')->once()->andReturn($user->name);
+        $mockSocialUser->shouldReceive('getAvatar')->once()->andReturn('avatar_placeholder');
+
+        $mockSocialDriver->shouldReceive('user')->times(2)->andReturn($mockSocialUser);
+        $mockSocialite->shouldReceive('driver')->times(4)->with('google')->andReturn($mockSocialDriver);
+        $mockSocialDriver->shouldReceive('redirect')->twice()->andReturn(redirect('/'));
+
+        $googleAccountNotUsedMessage = trans('errors.social_account_not_used', ['socialAccount' => 'Google']);
+
+        $this->get('/login/service/google');
+        $resp = $this->followingRedirects()->get('/login/service/google/callback');
+        $resp->assertSee($googleAccountNotUsedMessage);
+
+        config(['services.google.auto_register' => true]);
+
+        $this->get('/login/service/google');
+        $resp = $this->followingRedirects()->get('/login/service/google/callback');
+        $resp->assertDontSee($googleAccountNotUsedMessage);
+
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
+        $user = $user->whereEmail($user->email)->first();
+        $this->assertDatabaseHas('social_accounts', ['user_id' => $user->id]);
+    }
+
+    public function test_social_auto_email_confirm()
+    {
+        config([
+            'services.google.client_id' => 'abc123', 'services.google.client_secret' => '123abc',
+            'APP_URL' => 'http://localhost', 'services.google.auto_register' => true, 'services.google.auto_confirm' => true
+        ]);
+
+        $user = factory(\BookStack\User::class)->make();
+        $mockSocialite = \Mockery::mock('Laravel\Socialite\Contracts\Factory');
+        $this->app['Laravel\Socialite\Contracts\Factory'] = $mockSocialite;
+        $mockSocialDriver = \Mockery::mock('Laravel\Socialite\Contracts\Provider');
+        $mockSocialUser = \Mockery::mock('\Laravel\Socialite\Contracts\User');
+
+        $mockSocialUser->shouldReceive('getId')->times(3)->andReturn(1);
+        $mockSocialUser->shouldReceive('getEmail')->times(2)->andReturn($user->email);
+        $mockSocialUser->shouldReceive('getName')->once()->andReturn($user->name);
+        $mockSocialUser->shouldReceive('getAvatar')->once()->andReturn('avatar_placeholder');
+
+        $mockSocialDriver->shouldReceive('user')->times(1)->andReturn($mockSocialUser);
+        $mockSocialite->shouldReceive('driver')->times(2)->with('google')->andReturn($mockSocialDriver);
+        $mockSocialDriver->shouldReceive('redirect')->once()->andReturn(redirect('/'));
+
+        $this->get('/login/service/google');
+        $this->get('/login/service/google/callback');
+
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => true]);
+        $user = $user->whereEmail($user->email)->first();
+        $this->assertDatabaseHas('social_accounts', ['user_id' => $user->id]);
     }
 
 }
diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php
new file mode 100644 (file)
index 0000000..9071e3c
--- /dev/null
@@ -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]);
+    }
+
+}
index 7fa485f2052ffb0214a737abd51c8b9788fbf8e2..5fff84b8dfaa79dadc6efec5ff4202ca6f9a97c3 100644 (file)
@@ -15,7 +15,7 @@ class ExportTest extends TestCase
         $resp = $this->get($page->getUrl('/export/plaintext'));
         $resp->assertStatus(200);
         $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
     }
 
     public function test_page_pdf_export()
@@ -25,7 +25,7 @@ class ExportTest extends TestCase
 
         $resp = $this->get($page->getUrl('/export/pdf'));
         $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
     }
 
     public function test_page_html_export()
@@ -36,7 +36,7 @@ class ExportTest extends TestCase
         $resp = $this->get($page->getUrl('/export/html'));
         $resp->assertStatus(200);
         $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
     }
 
     public function test_book_text_export()
@@ -49,7 +49,7 @@ class ExportTest extends TestCase
         $resp->assertStatus(200);
         $resp->assertSee($book->name);
         $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
     }
 
     public function test_book_pdf_export()
@@ -60,7 +60,7 @@ class ExportTest extends TestCase
 
         $resp = $this->get($book->getUrl('/export/pdf'));
         $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
     }
 
     public function test_book_html_export()
@@ -73,7 +73,7 @@ class ExportTest extends TestCase
         $resp->assertStatus(200);
         $resp->assertSee($book->name);
         $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
     }
 
     public function test_chapter_text_export()
@@ -86,7 +86,7 @@ class ExportTest extends TestCase
         $resp->assertStatus(200);
         $resp->assertSee($chapter->name);
         $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
     }
 
     public function test_chapter_pdf_export()
@@ -96,7 +96,7 @@ class ExportTest extends TestCase
 
         $resp = $this->get($chapter->getUrl('/export/pdf'));
         $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
     }
 
     public function test_chapter_html_export()
@@ -109,7 +109,18 @@ class ExportTest extends TestCase
         $resp->assertStatus(200);
         $resp->assertSee($chapter->name);
         $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
+    }
+
+    public function test_page_html_export_contains_custom_head_if_set()
+    {
+        $page = Page::first();
+
+        $customHeadContent = "<style>p{color: red;}</style>";
+        $this->setSettings(['app-custom-head' => $customHeadContent]);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertSee($customHeadContent);
     }
 
 }
\ No newline at end of file
index beebc7adf4e9682667fa9044a73e3579f4ba70e5..08b379107f3bf049c6b26f8efc3a48662ba9c755 100644 (file)
@@ -11,7 +11,6 @@ class PageRevisionTest extends TestCase
     {
         $page = Page::first();
         $startCount = $page->revision_count;
-
         $resp = $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']);
         $resp->assertStatus(302);
 
@@ -22,11 +21,43 @@ class PageRevisionTest extends TestCase
     {
         $page = Page::first();
         $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']);
-        $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']);
+
         $page = Page::find($page->id);
+        $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']);
 
+        $page = Page::find($page->id);
         $pageView = $this->get($page->getUrl());
         $pageView->assertSee('Revision #' . $page->revision_count);
     }
 
+    public function test_revision_deletion() {
+        $page = Page::first();
+        $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']);
+
+        $page = Page::find($page->id);
+        $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']);
+
+        $page = Page::find($page->id);
+        $beforeRevisionCount = $page->revisions->count();
+
+        // Delete the first revision
+        $revision = $page->revisions->get(1);
+        $resp = $this->asEditor()->delete($revision->getUrl('/delete/'));
+        $resp->assertStatus(200);
+
+        $page = Page::find($page->id);
+        $afterRevisionCount = $page->revisions->count();
+
+        $this->assertTrue($beforeRevisionCount === ($afterRevisionCount + 1));
+
+        // Try to delete the latest revision
+        $beforeRevisionCount = $page->revisions->count();
+        $currentRevision = $page->getCurrentRevision();
+        $resp = $this->asEditor()->delete($currentRevision->getUrl('/delete/'));
+        $resp->assertStatus(400);
+
+        $page = Page::find($page->id);
+        $afterRevisionCount = $page->revisions->count();
+        $this->assertTrue($beforeRevisionCount === $afterRevisionCount);
+    }
 }
\ No newline at end of file
index c9b5a0109cc2544cc072c824caf737658e0602aa..a5e4a4a5e58b9d1e237fa37240cca6d7079154de 100644 (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');
     }
 }
\ No newline at end of file
index 29e0985c3c416b47b340a198aaf472f95d956b90..86cae7893a4b2e4e70f9ae60e1cedb6b4d001e2a 100644 (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();
     }
 }
index bf0ebbeaea37f06a1700eca7f58d00b0674d4b4c..2b3b00ac098360d34a2691323b47487ff6d9e824 100644 (file)
@@ -72,4 +72,13 @@ class LanguageTest extends TestCase
         }
     }
 
+    public function test_rtl_config_set_if_lang_is_rtl()
+    {
+        $this->asEditor();
+        $this->assertFalse(config('app.rtl'), "App RTL config should be false by default");
+        setting()->putUser($this->getEditor(), 'language', 'ar');
+        $this->get('/');
+        $this->assertTrue(config('app.rtl'), "App RTL config should have been set to true by middleware");
+    }
+
 }
\ No newline at end of file
index 2bbb1a5faeee9d08a37ebf704834da732ec4f70f..540125fd11129cd0a80432d9b4ed3987ba9da92b 100644 (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();
index f076e6734c98bc07fec49edf342aa9cbe5a131f9..e0f827d0246af435bcc8f93d80c7f56895e35384 100644 (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', [
index 325979e74fbf4782c4e11ca061170ebad69b0591..581dac5f1e954014f67c809e57dd4d0e7d42e87f 100644 (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);
+    }
+
 }
\ No newline at end of file
index e0f160eed7a751e6702aac3fc04d1f2c86101181..939a1a91e8e8adf51a3a131196b8defda0e28fa9 100644 (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);
     }
 }
\ No newline at end of file
diff --git a/tests/TestResponse.php b/tests/TestResponse.php
new file mode 100644 (file)
index 0000000..a68a578
--- /dev/null
@@ -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})";
+    }
+
+}